From e372a09eed47377a30a1547881add2350275b0bf Mon Sep 17 00:00:00 2001 From: Jens Timmerman Date: Sat, 4 Apr 2026 15:34:14 +0200 Subject: [PATCH 01/43] Added guntamatic heater integration --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/guntamatic_sensor/README.md | 38 +++++++++ .../components/guntamatic_sensor/__init__.py | 80 +++++++++++++++++ .../guntamatic_sensor/config_flow.py | 72 ++++++++++++++++ .../components/guntamatic_sensor/const.py | 6 ++ .../guntamatic_sensor/manifest.json | 15 ++++ .../guntamatic_sensor/quality_scale.yaml | 60 +++++++++++++ .../components/guntamatic_sensor/sensor.py | 76 +++++++++++++++++ .../components/guntamatic_sensor/strings.json | 25 ++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 ++ mypy.ini | 10 +++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + .../components/guntamatic_sensor/__init__.py | 1 + .../components/guntamatic_sensor/conftest.py | 16 ++++ .../guntamatic_sensor/test_config_flow.py | 85 +++++++++++++++++++ 18 files changed, 500 insertions(+) create mode 100644 homeassistant/components/guntamatic_sensor/README.md create mode 100644 homeassistant/components/guntamatic_sensor/__init__.py create mode 100644 homeassistant/components/guntamatic_sensor/config_flow.py create mode 100644 homeassistant/components/guntamatic_sensor/const.py create mode 100644 homeassistant/components/guntamatic_sensor/manifest.json create mode 100644 homeassistant/components/guntamatic_sensor/quality_scale.yaml create mode 100644 homeassistant/components/guntamatic_sensor/sensor.py create mode 100644 homeassistant/components/guntamatic_sensor/strings.json create mode 100644 tests/components/guntamatic_sensor/__init__.py create mode 100644 tests/components/guntamatic_sensor/conftest.py create mode 100644 tests/components/guntamatic_sensor/test_config_flow.py diff --git a/.strict-typing b/.strict-typing index 5e1549256616c..a34eb8d2cef2f 100644 --- a/.strict-typing +++ b/.strict-typing @@ -245,6 +245,7 @@ homeassistant.components.gpsd.* homeassistant.components.greeneye_monitor.* homeassistant.components.group.* homeassistant.components.guardian.* +homeassistant.components.guntamatic_sensor.* homeassistant.components.habitica.* homeassistant.components.hardkernel.* homeassistant.components.hardware.* diff --git a/CODEOWNERS b/CODEOWNERS index 1662d1b3df0cc..48fdfb42e1357 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -676,6 +676,8 @@ CLAUDE.md @home-assistant/core /tests/components/growatt_server/ @johanzander /homeassistant/components/guardian/ @bachya /tests/components/guardian/ @bachya +/homeassistant/components/guntamatic_sensor/ @JensTimmerman +/tests/components/guntamatic_sensor/ @JensTimmerman /homeassistant/components/habitica/ @tr4nt0r /tests/components/habitica/ @tr4nt0r /homeassistant/components/hanna/ @bestycame diff --git a/homeassistant/components/guntamatic_sensor/README.md b/homeassistant/components/guntamatic_sensor/README.md new file mode 100644 index 0000000000000..d2e8d820fb5c3 --- /dev/null +++ b/homeassistant/components/guntamatic_sensor/README.md @@ -0,0 +1,38 @@ +# Guntamatic Sensor + +## High-Level Description +The Guntamatic Sensor integration allows Home Assistant to monitor sensors from Guntamatic heaters. Guntamatic is a brand of modern wood/pellet gas boilers. This integration exposes temperature, operational state, and other relevant sensors. + + +## Sensors +The integration currently exposes the following sensors (dynamic values): + +- Boiler state: Running, STANDBY +- Boiler temperature +- Outside temperature +- Buffer load and buffer top/mid/bottom temperatures +- Boiler shunt pump, suction fan, primary and secondary air +- CO₂ content +- Domestic hot water (DHW) temperatures and pumps +- Heating circulation pumps and flow temperatures for multiple zones +- Program states (HEAT/HC) +- Interruptions +- Serial number, version, operation time, service hours +- Auxiliary pumps +- Additional WW/Buffer sensors + +## Installation Instructions +1. Copy the `guntamatic_sensor` folder into `config/custom_components/`. +2. Restart Home Assistant. +3. Go to **Settings → Devices & Services → Add Integration**. +4. Search for "Guntamatic Sensor" and enter the host address of your heater. + +## Removal Instructions +1. Go to **Settings → Devices & Services**. +2. Select the Guntamatic Sensor integration. +3. Click **Delete** to remove the integration and its entities. +4. Optional: Delete the `guntamatic_sensor` folder from `custom_components/`. + +## Services +This integration does **not** provide any Home Assistant service calls. + diff --git a/homeassistant/components/guntamatic_sensor/__init__.py b/homeassistant/components/guntamatic_sensor/__init__.py new file mode 100644 index 0000000000000..8b8d9be8c73bb --- /dev/null +++ b/homeassistant/components/guntamatic_sensor/__init__.py @@ -0,0 +1,80 @@ +"""The guntamatic integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging + +from guntamatic.heater import Heater + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import SCAN_INTERVAL + +# For your initial PR, limit it to 1 platform. +_LOGGER = logging.getLogger(__name__) +_PLATFORMS: list[Platform] = [Platform.SENSOR] + +type GuntamaticConfigEntry = ConfigEntry[Heater] + + +@dataclass +class GuntamaticData: + """Data for the Guntamatic integration.""" + + heater: Heater + coordinator: DataUpdateCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: GuntamaticConfigEntry) -> bool: + """Set up guntamatic from a config entry.""" + + host = entry.data["host"] + heater = Heater(host) + if not await hass.async_add_executor_job(heater.get_data): + raise ConfigEntryNotReady("Cannot connect to Guntamatic heater") + + async def async_update_data(): + """Fetch all sensor data from the heater. + + Expected return format: + { + "Boiler Temperature": [68.5, "°C"], + "Flue Temperature": [115.2, "°C"], + "Power Output": [12.4, "kW"], + } + """ + + data = await hass.async_add_executor_job(heater.get_data) + if not data: + raise UpdateFailed("No data received from heater") + return data + + coordinator = DataUpdateCoordinator( + hass, + logger=_LOGGER, + name="guntamatic_sensor", + update_method=async_update_data, + update_interval=SCAN_INTERVAL, + config_entry=entry, + ) + + try: + # Fetch initial data + await coordinator.async_config_entry_first_refresh() + except Exception as err: + raise ConfigEntryNotReady(f"Error while connecting to {host}: {err}") from err + + entry.runtime_data = GuntamaticData(heater=heater, coordinator=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) diff --git a/homeassistant/components/guntamatic_sensor/config_flow.py b/homeassistant/components/guntamatic_sensor/config_flow.py new file mode 100644 index 0000000000000..4935118991c19 --- /dev/null +++ b/homeassistant/components/guntamatic_sensor/config_flow.py @@ -0,0 +1,72 @@ +"""Config flow for the guntamatic integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from guntamatic.heater import Heater +import requests +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + heater = Heater(data[CONF_HOST]) + + if not await hass.async_add_executor_job(heater.get_data): + raise CannotConnect + + # Return info that you want to store in the config entry. + return {"host": data[CONF_HOST]} + + +class GuntamaticConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for guntamatic.""" + + VERSION = 1 + + 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: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + try: + await validate_input(self.hass, user_input) + except CannotConnect, requests.exceptions.ConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + 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 + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/guntamatic_sensor/const.py b/homeassistant/components/guntamatic_sensor/const.py new file mode 100644 index 0000000000000..973b11ad1aad9 --- /dev/null +++ b/homeassistant/components/guntamatic_sensor/const.py @@ -0,0 +1,6 @@ +"""Constants for the guntamatic integration.""" + +from datetime import timedelta + +DOMAIN = "guntamatic_sensor" +SCAN_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/guntamatic_sensor/manifest.json b/homeassistant/components/guntamatic_sensor/manifest.json new file mode 100644 index 0000000000000..465dccebe966b --- /dev/null +++ b/homeassistant/components/guntamatic_sensor/manifest.json @@ -0,0 +1,15 @@ +{ + "domain": "guntamatic_sensor", + "name": "guntamatic", + "codeowners": ["@JensTimmerman"], + "config_flow": true, + "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/guntamatic_sensor", + "homekit": {}, + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["guntamatic==1.0.3"], + "ssdp": [], + "zeroconf": [] +} diff --git a/homeassistant/components/guntamatic_sensor/quality_scale.yaml b/homeassistant/components/guntamatic_sensor/quality_scale.yaml new file mode 100644 index 0000000000000..76b8d347408bf --- /dev/null +++ b/homeassistant/components/guntamatic_sensor/quality_scale.yaml @@ -0,0 +1,60 @@ +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: done + 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: todo + config-entry-unloading: todo + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: todo + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: todo + diagnostics: todo + discovery-update-info: todo + discovery: todo + 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: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + 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 diff --git a/homeassistant/components/guntamatic_sensor/sensor.py b/homeassistant/components/guntamatic_sensor/sensor.py new file mode 100644 index 0000000000000..24172e4fb5f27 --- /dev/null +++ b/homeassistant/components/guntamatic_sensor/sensor.py @@ -0,0 +1,76 @@ +"""Support for Guntamatic sensors in Home Assistant.""" + +from datetime import timedelta + +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.config_entries import ConfigEntry +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 + +UPDATE_INTERVAL = timedelta(seconds=60) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Guntamatic sensors from config entry.""" + + data = entry.runtime_data + coordinator = data.coordinator + heater = data.heater + + # Create one entity per sensor + sensors = [ + GuntamaticSensor(coordinator, name, heater.host) for name in coordinator.data + ] + + async_add_entities(sensors) + + +class GuntamaticSensor(CoordinatorEntity, SensorEntity): + """Representation of a single Guntamatic sensor.""" + + def __init__(self, coordinator, name, host): + """Initialize the sensor.""" + super().__init__(coordinator) + self._name = name + self._attr_has_entity_name = True + self._attr_name = name + # serial might not be set on all devices + serial = coordinator.data.get("Serial", [None])[0] or host + + self._attr_unique_id = ( + f"guntamatic_{serial.replace('.', '_')}_{name.replace(' ', '_')}" + ) + + unit = coordinator.data[name][1] + self._attr_native_unit_of_measurement = unit + # if no unit is given by the guntamatic, it's a string, so set None + if not unit: + self._attr_native_unit_of_measurement = None + self._attr_state_class = None + self._attr_device_class = SensorDeviceClass.ENUM + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, serial)}, + name="Guntamatic Heater", + manufacturer="Guntamatic", + serial_number=serial if serial != host else None, + sw_version=coordinator.data.get("Version", [None])[0], + ) + + @property + def native_value(self): + """Return the current value of the sensor.""" + return self.coordinator.data[self._attr_name][0] + + @property + def native_unit_of_measurement(self): + """Return the current unit of the sensor.""" + return self.coordinator.data[self._attr_name][1] diff --git a/homeassistant/components/guntamatic_sensor/strings.json b/homeassistant/components/guntamatic_sensor/strings.json new file mode 100644 index 0000000000000..d9145f23e0a2f --- /dev/null +++ b/homeassistant/components/guntamatic_sensor/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "not_implemented": "Not Implemented" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "The hostname or IP address of your Guntamatic heater." + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b28eb0a3c74e3..d66d298f9a859 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -282,6 +282,7 @@ "green_planet_energy", "growatt_server", "guardian", + "guntamatic_sensor", "habitica", "hanna", "harmony", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a13d5b35294a1..4749bf2d7e315 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2683,6 +2683,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "guntamatic_sensor": { + "name": "guntamatic", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "habitica": { "name": "Habitica", "integration_type": "service", diff --git a/mypy.ini b/mypy.ini index 0ca25a2f94ba2..3597eb0c5bf81 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2205,6 +2205,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.guntamatic_sensor.*] +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 diff --git a/requirements_all.txt b/requirements_all.txt index 04ba2eb0b2cfe..f96ebebf40c2e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1154,6 +1154,9 @@ growattServer==1.9.0 # homeassistant.components.google_sheets gspread==5.5.0 +# homeassistant.components.guntamatic_sensor +guntamatic==1.0.3 + # homeassistant.components.profiler guppy3==3.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aa2d485307df4..4a1de96be9894 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1027,6 +1027,9 @@ growattServer==1.9.0 # homeassistant.components.google_sheets gspread==5.5.0 +# homeassistant.components.guntamatic_sensor +guntamatic==1.0.3 + # homeassistant.components.profiler guppy3==3.1.6 diff --git a/tests/components/guntamatic_sensor/__init__.py b/tests/components/guntamatic_sensor/__init__.py new file mode 100644 index 0000000000000..b6d6c20a2367a --- /dev/null +++ b/tests/components/guntamatic_sensor/__init__.py @@ -0,0 +1 @@ +"""Tests for the guntamatic integration.""" diff --git a/tests/components/guntamatic_sensor/conftest.py b/tests/components/guntamatic_sensor/conftest.py new file mode 100644 index 0000000000000..bf8cb978fc6e7 --- /dev/null +++ b/tests/components/guntamatic_sensor/conftest.py @@ -0,0 +1,16 @@ +"""Common fixtures for the guntamatic tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.guntamatic_sensor.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/guntamatic_sensor/test_config_flow.py b/tests/components/guntamatic_sensor/test_config_flow.py new file mode 100644 index 0000000000000..0fbc3dc79874b --- /dev/null +++ b/tests/components/guntamatic_sensor/test_config_flow.py @@ -0,0 +1,85 @@ +"""Test the guntamatic config flow.""" + +from unittest.mock import AsyncMock, patch + +import requests + +from homeassistant import config_entries +from homeassistant.components.guntamatic_sensor.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.guntamatic_sensor.config_flow.Heater.get_data", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Guntamatic Heater" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.guntamatic_sensor.config_flow.Heater.get_data", + side_effect=requests.exceptions.ConnectionError, + ): + 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": "cannot_connect"} + + # Make sure the config flow tests finish with either an + # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + + with patch( + "homeassistant.components.guntamatic_sensor.config_flow.Heater.get_data", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Guntamatic Heater" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + } + assert len(mock_setup_entry.mock_calls) == 1 From 3d7c7b7550a36bf5a7d1a56ad26ca2171927c4d7 Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Sun, 5 Apr 2026 03:59:52 +0200 Subject: [PATCH 02/43] Addressed copilot comments to guntamatic integration --- .../components/guntamatic_sensor/__init__.py | 45 ++++++++---- .../guntamatic_sensor/config_flow.py | 5 +- .../components/guntamatic_sensor/const.py | 25 +++++++ .../guntamatic_sensor/diagnostics.py | 19 ++++++ .../guntamatic_sensor/manifest.json | 2 +- .../guntamatic_sensor/quality_scale.yaml | 48 ++++++++----- .../components/guntamatic_sensor/sensor.py | 68 +++++++++++++------ .../components/guntamatic_sensor/strings.json | 4 +- .../components/guntamatic_sensor/conftest.py | 39 ++++++++++- .../guntamatic_sensor/test_config_flow.py | 62 +++++++++++++++++ .../guntamatic_sensor/test_diagnostics.py | 28 ++++++++ .../components/guntamatic_sensor/test_init.py | 60 ++++++++++++++++ .../guntamatic_sensor/test_sensor.py | 38 +++++++++++ 13 files changed, 387 insertions(+), 56 deletions(-) create mode 100644 homeassistant/components/guntamatic_sensor/diagnostics.py create mode 100644 tests/components/guntamatic_sensor/test_diagnostics.py create mode 100644 tests/components/guntamatic_sensor/test_init.py create mode 100644 tests/components/guntamatic_sensor/test_sensor.py diff --git a/homeassistant/components/guntamatic_sensor/__init__.py b/homeassistant/components/guntamatic_sensor/__init__.py index 8b8d9be8c73bb..1702c42fb6818 100644 --- a/homeassistant/components/guntamatic_sensor/__init__.py +++ b/homeassistant/components/guntamatic_sensor/__init__.py @@ -8,12 +8,13 @@ from guntamatic.heater import Heater from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import SCAN_INTERVAL +from .const import DOMAIN, SCAN_INTERVAL # For your initial PR, limit it to 1 platform. _LOGGER = logging.getLogger(__name__) @@ -33,12 +34,22 @@ class GuntamaticData: async def async_setup_entry(hass: HomeAssistant, entry: GuntamaticConfigEntry) -> bool: """Set up guntamatic from a config entry.""" - host = entry.data["host"] + host = entry.data[CONF_HOST] heater = Heater(host) - if not await hass.async_add_executor_job(heater.get_data): + + # initial connectivity check + initial_data = None + try: + initial_data = await hass.async_add_executor_job(heater.get_data) + except Exception as err: + raise ConfigEntryNotReady( + f"Cannot connect to Guntamatic heater: {err}" + ) from err + + if not initial_data: raise ConfigEntryNotReady("Cannot connect to Guntamatic heater") - async def async_update_data(): + async def async_update_data() -> dict[str, list[str]]: """Fetch all sensor data from the heater. Expected return format: @@ -49,7 +60,7 @@ async def async_update_data(): } """ - data = await hass.async_add_executor_job(heater.get_data) + data: dict[str, list[str]] = await hass.async_add_executor_job(heater.get_data) if not data: raise UpdateFailed("No data received from heater") return data @@ -63,12 +74,7 @@ async def async_update_data(): config_entry=entry, ) - try: - # Fetch initial data - await coordinator.async_config_entry_first_refresh() - except Exception as err: - raise ConfigEntryNotReady(f"Error while connecting to {host}: {err}") from err - + coordinator.data = initial_data entry.runtime_data = GuntamaticData(heater=heater, coordinator=coordinator) await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) @@ -78,3 +84,18 @@ async def async_update_data(): async def async_unload_entry(hass: HomeAssistant, entry: GuntamaticConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, + config_entry: GuntamaticConfigEntry, + device_entry: dr.DeviceEntry, +) -> bool: + """Remove a config entry from a device.""" + return not any( + identifier + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + and identifier[1] + == config_entry.runtime_data.coordinator.data.get("Serial", [None])[0] + ) diff --git a/homeassistant/components/guntamatic_sensor/config_flow.py b/homeassistant/components/guntamatic_sensor/config_flow.py index 4935118991c19..8bb1938f5a8eb 100644 --- a/homeassistant/components/guntamatic_sensor/config_flow.py +++ b/homeassistant/components/guntamatic_sensor/config_flow.py @@ -52,7 +52,7 @@ async def async_step_user( if user_input is not None: self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) try: - await validate_input(self.hass, user_input) + info = await validate_input(self.hass, user_input) except CannotConnect, requests.exceptions.ConnectionError: errors["base"] = "cannot_connect" except Exception: @@ -60,7 +60,8 @@ async def async_step_user( errors["base"] = "unknown" else: return self.async_create_entry( - title="Guntamatic Heater", data=user_input + title="Guntamatic Heater", + data=info, ) return self.async_show_form( diff --git a/homeassistant/components/guntamatic_sensor/const.py b/homeassistant/components/guntamatic_sensor/const.py index 973b11ad1aad9..aa394f4eb80cd 100644 --- a/homeassistant/components/guntamatic_sensor/const.py +++ b/homeassistant/components/guntamatic_sensor/const.py @@ -2,5 +2,30 @@ from datetime import timedelta +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass + DOMAIN = "guntamatic_sensor" SCAN_INTERVAL = timedelta(seconds=30) +DIAGNOSTIC_SENSORS = {"Serial", "Version", "Operat. time", "Service Hrs"} + +SENSOR_DEVICE_CLASSES: dict[str, SensorDeviceClass | None] = { + "Boiler temperature": SensorDeviceClass.TEMPERATURE, + "Outside Temp.": SensorDeviceClass.TEMPERATURE, + "Buffer Top": SensorDeviceClass.TEMPERATURE, + "Buffer Mid": SensorDeviceClass.TEMPERATURE, + "Buffer Btm": SensorDeviceClass.TEMPERATURE, + "DHW 0": SensorDeviceClass.TEMPERATURE, + "DHW 1": SensorDeviceClass.TEMPERATURE, + "DHW 2": SensorDeviceClass.TEMPERATURE, + # co2 content is explicitly not a co2 device class, this is flue gas, which will be 900.000ppm it doesn't make sense + # to put it in the category of indoor air quality measurement devices + # "CO2 Content": SensorDeviceClass.CO2, + "Operat. time": SensorDeviceClass.DURATION, +} + +SENSOR_STATE_CLASSES: dict[str, SensorStateClass | None] = { + "Boiler temperature": SensorStateClass.MEASUREMENT, + "Outside Temp.": SensorStateClass.MEASUREMENT, + "Buffer load.": SensorStateClass.MEASUREMENT, + "CO2 Content": SensorStateClass.MEASUREMENT, +} diff --git a/homeassistant/components/guntamatic_sensor/diagnostics.py b/homeassistant/components/guntamatic_sensor/diagnostics.py new file mode 100644 index 0000000000000..171bfd6ef2118 --- /dev/null +++ b/homeassistant/components/guntamatic_sensor/diagnostics.py @@ -0,0 +1,19 @@ +"""Diagnostics support for Guntamatic.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant + +from . import GuntamaticConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: GuntamaticConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return { + "entry_data": dict(entry.data), + "data": entry.runtime_data.coordinator.data, + } diff --git a/homeassistant/components/guntamatic_sensor/manifest.json b/homeassistant/components/guntamatic_sensor/manifest.json index 465dccebe966b..3637ec2ec76f2 100644 --- a/homeassistant/components/guntamatic_sensor/manifest.json +++ b/homeassistant/components/guntamatic_sensor/manifest.json @@ -8,7 +8,7 @@ "homekit": {}, "integration_type": "device", "iot_class": "local_polling", - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["guntamatic==1.0.3"], "ssdp": [], "zeroconf": [] diff --git a/homeassistant/components/guntamatic_sensor/quality_scale.yaml b/homeassistant/components/guntamatic_sensor/quality_scale.yaml index 76b8d347408bf..5bf110fc2d029 100644 --- a/homeassistant/components/guntamatic_sensor/quality_scale.yaml +++ b/homeassistant/components/guntamatic_sensor/quality_scale.yaml @@ -7,7 +7,10 @@ rules: config-flow-test-coverage: done config-flow: done dependency-transparency: done - docs-actions: done + docs-actions: + status: exempt + comment: | + No custom actions are defined. docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done @@ -20,22 +23,33 @@ rules: unique-config-entry: done # Silver - action-exceptions: todo - config-entry-unloading: todo - docs-configuration-parameters: todo - docs-installation-parameters: todo - entity-unavailable: todo - integration-owner: todo - log-when-unavailable: todo - parallel-updates: todo - reauthentication-flow: todo - test-coverage: todo + 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: todo - diagnostics: todo - discovery-update-info: todo - discovery: todo + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: Guntamatic heaters do not support network discovery protocols. + discovery: + status: exempt + comment: Guntamatic heaters do not support network discovery protocols. docs-data-update: todo docs-examples: todo docs-known-limitations: todo @@ -43,8 +57,8 @@ rules: docs-supported-functions: todo docs-troubleshooting: todo docs-use-cases: todo - dynamic-devices: todo - entity-category: todo + dynamic-devices: done + entity-category: done entity-device-class: todo entity-disabled-by-default: todo entity-translations: todo diff --git a/homeassistant/components/guntamatic_sensor/sensor.py b/homeassistant/components/guntamatic_sensor/sensor.py index 24172e4fb5f27..6ea994fde97a0 100644 --- a/homeassistant/components/guntamatic_sensor/sensor.py +++ b/homeassistant/components/guntamatic_sensor/sensor.py @@ -1,22 +1,31 @@ """Support for Guntamatic sensors in Home Assistant.""" -from datetime import timedelta - -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, StateType +from homeassistant.const import EntityCategory 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 homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from . import GuntamaticConfigEntry +from .const import ( + DIAGNOSTIC_SENSORS, + DOMAIN, + SENSOR_DEVICE_CLASSES, + SENSOR_STATE_CLASSES, +) -from .const import DOMAIN +PARALLEL_UPDATES = 0 -UPDATE_INTERVAL = timedelta(seconds=60) +type GuntamaticCoordinator = DataUpdateCoordinator[dict[str, list[str]]] async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GuntamaticConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Guntamatic sensors from config entry.""" @@ -25,18 +34,30 @@ async def async_setup_entry( coordinator = data.coordinator heater = data.heater - # Create one entity per sensor - sensors = [ - GuntamaticSensor(coordinator, name, heater.host) for name in coordinator.data - ] + known_sensors: set[str] = set() + + def _check_sensors() -> None: + current_sensors = set(coordinator.data) + new_sensors = current_sensors - known_sensors + if new_sensors: + known_sensors.update(new_sensors) + async_add_entities( + GuntamaticSensor(coordinator, name, heater.host) for name in new_sensors + ) - async_add_entities(sensors) + _check_sensors() + entry.async_on_unload(coordinator.async_add_listener(_check_sensors)) -class GuntamaticSensor(CoordinatorEntity, SensorEntity): +class GuntamaticSensor(CoordinatorEntity[GuntamaticCoordinator], SensorEntity): """Representation of a single Guntamatic sensor.""" - def __init__(self, coordinator, name, host): + def __init__( + self, + coordinator: GuntamaticCoordinator, + name: str, + host: str, + ) -> None: """Initialize the sensor.""" super().__init__(coordinator) self._name = name @@ -51,6 +72,13 @@ def __init__(self, coordinator, name, host): unit = coordinator.data[name][1] self._attr_native_unit_of_measurement = unit + self._attr_device_class = SENSOR_DEVICE_CLASSES.get(name) + self._attr_state_class = SENSOR_STATE_CLASSES.get(name) + + self._attr_entity_category = ( + EntityCategory.DIAGNOSTIC if name in DIAGNOSTIC_SENSORS else None + ) + # if no unit is given by the guntamatic, it's a string, so set None if not unit: self._attr_native_unit_of_measurement = None @@ -66,11 +94,11 @@ def __init__(self, coordinator, name, host): ) @property - def native_value(self): + def native_value(self) -> StateType: """Return the current value of the sensor.""" - return self.coordinator.data[self._attr_name][0] + return self.coordinator.data[self._name][0] @property - def native_unit_of_measurement(self): - """Return the current unit of the sensor.""" - return self.coordinator.data[self._attr_name][1] + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self._name in self.coordinator.data diff --git a/homeassistant/components/guntamatic_sensor/strings.json b/homeassistant/components/guntamatic_sensor/strings.json index d9145f23e0a2f..58e6706335341 100644 --- a/homeassistant/components/guntamatic_sensor/strings.json +++ b/homeassistant/components/guntamatic_sensor/strings.json @@ -12,9 +12,7 @@ "step": { "user": { "data": { - "host": "[%key:common::config_flow::data::host%]", - "password": "[%key:common::config_flow::data::password%]", - "username": "[%key:common::config_flow::data::username%]" + "host": "[%key:common::config_flow::data::host%]" }, "data_description": { "host": "The hostname or IP address of your Guntamatic heater." diff --git a/tests/components/guntamatic_sensor/conftest.py b/tests/components/guntamatic_sensor/conftest.py index bf8cb978fc6e7..f448336095a2b 100644 --- a/tests/components/guntamatic_sensor/conftest.py +++ b/tests/components/guntamatic_sensor/conftest.py @@ -1,10 +1,47 @@ """Common fixtures for the guntamatic tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest +from homeassistant.components.guntamatic_sensor.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +MOCK_DATA = { + "Boiler temperature": ["14.09", "°C"], + "Outside Temp.": ["15.95", "°C"], + "Buffer load.": ["22", "%"], + "Program": ["HEAT", ""], + "Running": ["Service Ign.", ""], + "Serial": ["959103", ""], + "Version": ["32a", ""], +} + + +@pytest.fixture +def mock_heater() -> Generator[AsyncMock]: + """Mock the Heater class.""" + with patch( + "homeassistant.components.guntamatic_sensor.Heater", + autospec=True, + ) as mock: + mock.return_value.get_data = MagicMock(return_value=MOCK_DATA) + mock.return_value.host = "1.1.1.1" + yield mock + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "1.1.1.1"}, + ) + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: diff --git a/tests/components/guntamatic_sensor/test_config_flow.py b/tests/components/guntamatic_sensor/test_config_flow.py index 0fbc3dc79874b..32b7b3bc08f4e 100644 --- a/tests/components/guntamatic_sensor/test_config_flow.py +++ b/tests/components/guntamatic_sensor/test_config_flow.py @@ -10,6 +10,8 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry + async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" @@ -83,3 +85,63 @@ async def test_form_cannot_connect( CONF_HOST: "1.1.1.1", } assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_empty_data( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle empty data from heater.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.guntamatic_sensor.config_flow.Heater.get_data", + return_value={}, + ): + 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": "cannot_connect"} + + +async def test_form_unknown_error( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle unknown errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.guntamatic_sensor.config_flow.Heater.get_data", + side_effect=Exception("Unknown error"), + ): + 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": "unknown"} + + +async def test_form_already_configured( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we abort if already configured.""" + entry = MockConfigEntry(domain=DOMAIN, data={CONF_HOST: "1.1.1.1"}) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.guntamatic_sensor.config_flow.Heater.get_data", + return_value=True, + ): + 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" diff --git a/tests/components/guntamatic_sensor/test_diagnostics.py b/tests/components/guntamatic_sensor/test_diagnostics.py new file mode 100644 index 0000000000000..3631895a1a97e --- /dev/null +++ b/tests/components/guntamatic_sensor/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Test the Guntamatic diagnostics.""" + +from unittest.mock import MagicMock + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_heater: MagicMock, +) -> None: + """Test diagnostics.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert result["entry_data"] == {"host": "1.1.1.1"} + assert result["data"] == mock_heater.return_value.get_data.return_value diff --git a/tests/components/guntamatic_sensor/test_init.py b/tests/components/guntamatic_sensor/test_init.py new file mode 100644 index 0000000000000..12d3d046054b1 --- /dev/null +++ b/tests/components/guntamatic_sensor/test_init.py @@ -0,0 +1,60 @@ +"""Test the Guntamatic integration setup.""" + +from unittest.mock import MagicMock + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +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.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + + +async def test_setup_entry_cannot_connect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_heater: MagicMock, +) -> None: + """Test setup fails when heater is unreachable.""" + mock_heater.return_value.get_data.side_effect = Exception("Cannot connect") + mock_config_entry.add_to_hass(hass) + 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_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_heater: MagicMock, +) -> None: + """Test unloading the integration.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_setup_entry_empty_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_heater: MagicMock, +) -> None: + """Test setup fails when heater returns no data.""" + mock_heater.return_value.get_data.return_value = {} + mock_config_entry.add_to_hass(hass) + 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 diff --git a/tests/components/guntamatic_sensor/test_sensor.py b/tests/components/guntamatic_sensor/test_sensor.py new file mode 100644 index 0000000000000..faa98b7f5a8bf --- /dev/null +++ b/tests/components/guntamatic_sensor/test_sensor.py @@ -0,0 +1,38 @@ +"""Test the Guntamatic sensors.""" + +from unittest.mock import MagicMock + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_sensors_created( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_heater: MagicMock, +) -> None: + """Test that sensors are created for each data point.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.guntamatic_heater_boiler_temperature") + assert state is not None + assert state.state == "14.09" + assert state.attributes["unit_of_measurement"] == "°C" + + +async def test_sensor_native_value( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_heater: MagicMock, +) -> None: + """Test sensor returns correct native value.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.guntamatic_heater_outside_temp") + assert state is not None + assert state.state == "15.95" From d77f1ad491d6d450b26d40a6d4ec6f9cc4d01ce6 Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Mon, 6 Apr 2026 00:49:29 +0200 Subject: [PATCH 03/43] Addresses remarks in pr --- .../components/guntamatic_sensor/README.md | 38 --------- .../components/guntamatic_sensor/__init__.py | 50 ++---------- .../guntamatic_sensor/config_flow.py | 50 ++++++------ .../components/guntamatic_sensor/const.py | 26 ++---- .../guntamatic_sensor/coordinator.py | 51 ++++++++++++ .../guntamatic_sensor/diagnostics.py | 19 ----- .../guntamatic_sensor/manifest.json | 7 ++ .../guntamatic_sensor/quality_scale.yaml | 10 +-- .../components/guntamatic_sensor/sensor.py | 81 ++++++++++++------- homeassistant/generated/dhcp.py | 5 ++ .../guntamatic_sensor/test_config_flow.py | 22 +++++ .../guntamatic_sensor/test_coordinator.py | 46 +++++++++++ .../guntamatic_sensor/test_diagnostics.py | 28 ------- 13 files changed, 221 insertions(+), 212 deletions(-) delete mode 100644 homeassistant/components/guntamatic_sensor/README.md create mode 100644 homeassistant/components/guntamatic_sensor/coordinator.py delete mode 100644 homeassistant/components/guntamatic_sensor/diagnostics.py create mode 100644 tests/components/guntamatic_sensor/test_coordinator.py delete mode 100644 tests/components/guntamatic_sensor/test_diagnostics.py diff --git a/homeassistant/components/guntamatic_sensor/README.md b/homeassistant/components/guntamatic_sensor/README.md deleted file mode 100644 index d2e8d820fb5c3..0000000000000 --- a/homeassistant/components/guntamatic_sensor/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# Guntamatic Sensor - -## High-Level Description -The Guntamatic Sensor integration allows Home Assistant to monitor sensors from Guntamatic heaters. Guntamatic is a brand of modern wood/pellet gas boilers. This integration exposes temperature, operational state, and other relevant sensors. - - -## Sensors -The integration currently exposes the following sensors (dynamic values): - -- Boiler state: Running, STANDBY -- Boiler temperature -- Outside temperature -- Buffer load and buffer top/mid/bottom temperatures -- Boiler shunt pump, suction fan, primary and secondary air -- CO₂ content -- Domestic hot water (DHW) temperatures and pumps -- Heating circulation pumps and flow temperatures for multiple zones -- Program states (HEAT/HC) -- Interruptions -- Serial number, version, operation time, service hours -- Auxiliary pumps -- Additional WW/Buffer sensors - -## Installation Instructions -1. Copy the `guntamatic_sensor` folder into `config/custom_components/`. -2. Restart Home Assistant. -3. Go to **Settings → Devices & Services → Add Integration**. -4. Search for "Guntamatic Sensor" and enter the host address of your heater. - -## Removal Instructions -1. Go to **Settings → Devices & Services**. -2. Select the Guntamatic Sensor integration. -3. Click **Delete** to remove the integration and its entities. -4. Optional: Delete the `guntamatic_sensor` folder from `custom_components/`. - -## Services -This integration does **not** provide any Home Assistant service calls. - diff --git a/homeassistant/components/guntamatic_sensor/__init__.py b/homeassistant/components/guntamatic_sensor/__init__.py index 1702c42fb6818..632d1d6e82e76 100644 --- a/homeassistant/components/guntamatic_sensor/__init__.py +++ b/homeassistant/components/guntamatic_sensor/__init__.py @@ -11,16 +11,13 @@ from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, SCAN_INTERVAL +from .coordinator import GuntamaticCoordinator -# For your initial PR, limit it to 1 platform. _LOGGER = logging.getLogger(__name__) _PLATFORMS: list[Platform] = [Platform.SENSOR] -type GuntamaticConfigEntry = ConfigEntry[Heater] +type GuntamaticConfigEntry = ConfigEntry[GuntamaticData] @dataclass @@ -28,7 +25,7 @@ class GuntamaticData: """Data for the Guntamatic integration.""" heater: Heater - coordinator: DataUpdateCoordinator + coordinator: GuntamaticCoordinator async def async_setup_entry(hass: HomeAssistant, entry: GuntamaticConfigEntry) -> bool: @@ -49,30 +46,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: GuntamaticConfigEntry) - if not initial_data: raise ConfigEntryNotReady("Cannot connect to Guntamatic heater") - async def async_update_data() -> dict[str, list[str]]: - """Fetch all sensor data from the heater. - - Expected return format: - { - "Boiler Temperature": [68.5, "°C"], - "Flue Temperature": [115.2, "°C"], - "Power Output": [12.4, "kW"], - } - """ - - data: dict[str, list[str]] = await hass.async_add_executor_job(heater.get_data) - if not data: - raise UpdateFailed("No data received from heater") - return data - - coordinator = DataUpdateCoordinator( - hass, - logger=_LOGGER, - name="guntamatic_sensor", - update_method=async_update_data, - update_interval=SCAN_INTERVAL, - config_entry=entry, - ) + coordinator = GuntamaticCoordinator(hass, heater) + coordinator.config_entry = entry coordinator.data = initial_data entry.runtime_data = GuntamaticData(heater=heater, coordinator=coordinator) @@ -84,18 +59,3 @@ async def async_update_data() -> dict[str, list[str]]: async def async_unload_entry(hass: HomeAssistant, entry: GuntamaticConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) - - -async def async_remove_config_entry_device( - hass: HomeAssistant, - config_entry: GuntamaticConfigEntry, - device_entry: dr.DeviceEntry, -) -> bool: - """Remove a config entry from a device.""" - return not any( - identifier - for identifier in device_entry.identifiers - if identifier[0] == DOMAIN - and identifier[1] - == config_entry.runtime_data.coordinator.data.get("Serial", [None])[0] - ) diff --git a/homeassistant/components/guntamatic_sensor/config_flow.py b/homeassistant/components/guntamatic_sensor/config_flow.py index 8bb1938f5a8eb..da6e76790a6d8 100644 --- a/homeassistant/components/guntamatic_sensor/config_flow.py +++ b/homeassistant/components/guntamatic_sensor/config_flow.py @@ -11,8 +11,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN @@ -25,49 +24,48 @@ ) -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: - """Validate the user input allows us to connect. - - Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. - """ - heater = Heater(data[CONF_HOST]) - - if not await hass.async_add_executor_job(heater.get_data): - raise CannotConnect - - # Return info that you want to store in the config entry. - return {"host": data[CONF_HOST]} - - class GuntamaticConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for guntamatic.""" VERSION = 1 + _discovered_host = None + + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery.""" + await self.async_set_unique_id(discovery_info.macaddress) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) + + self._discovered_host = discovery_info.ip + return await self.async_step_user() 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 None and self._discovered_host is not None: + user_input = {CONF_HOST: self._discovered_host} if user_input is not None: self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) try: - info = await validate_input(self.hass, user_input) - except CannotConnect, requests.exceptions.ConnectionError: + heater = Heater(user_input[CONF_HOST]) + data = await self.hass.async_add_executor_job(heater.get_data) + except requests.exceptions.ConnectionError: errors["base"] = "cannot_connect" except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - return self.async_create_entry( - title="Guntamatic Heater", - data=info, - ) + if not data: + errors["base"] = "cannot_connect" + else: + 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 ) - - -class CannotConnect(HomeAssistantError): - """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/guntamatic_sensor/const.py b/homeassistant/components/guntamatic_sensor/const.py index aa394f4eb80cd..da67363853d63 100644 --- a/homeassistant/components/guntamatic_sensor/const.py +++ b/homeassistant/components/guntamatic_sensor/const.py @@ -8,24 +8,14 @@ SCAN_INTERVAL = timedelta(seconds=30) DIAGNOSTIC_SENSORS = {"Serial", "Version", "Operat. time", "Service Hrs"} -SENSOR_DEVICE_CLASSES: dict[str, SensorDeviceClass | None] = { - "Boiler temperature": SensorDeviceClass.TEMPERATURE, - "Outside Temp.": SensorDeviceClass.TEMPERATURE, - "Buffer Top": SensorDeviceClass.TEMPERATURE, - "Buffer Mid": SensorDeviceClass.TEMPERATURE, - "Buffer Btm": SensorDeviceClass.TEMPERATURE, - "DHW 0": SensorDeviceClass.TEMPERATURE, - "DHW 1": SensorDeviceClass.TEMPERATURE, - "DHW 2": SensorDeviceClass.TEMPERATURE, - # co2 content is explicitly not a co2 device class, this is flue gas, which will be 900.000ppm it doesn't make sense - # to put it in the category of indoor air quality measurement devices - # "CO2 Content": SensorDeviceClass.CO2, - "Operat. time": SensorDeviceClass.DURATION, + +UNIT_TO_DEVICE_CLASS: dict[str, SensorDeviceClass] = { + "°C": SensorDeviceClass.TEMPERATURE, + "h": SensorDeviceClass.DURATION, + "d": SensorDeviceClass.DURATION, } -SENSOR_STATE_CLASSES: dict[str, SensorStateClass | None] = { - "Boiler temperature": SensorStateClass.MEASUREMENT, - "Outside Temp.": SensorStateClass.MEASUREMENT, - "Buffer load.": SensorStateClass.MEASUREMENT, - "CO2 Content": SensorStateClass.MEASUREMENT, +UNIT_TO_STATE_CLASS: dict[str, SensorStateClass] = { + "°C": SensorStateClass.MEASUREMENT, + "%": SensorStateClass.MEASUREMENT, } diff --git a/homeassistant/components/guntamatic_sensor/coordinator.py b/homeassistant/components/guntamatic_sensor/coordinator.py new file mode 100644 index 0000000000000..2e1b96f32d4be --- /dev/null +++ b/homeassistant/components/guntamatic_sensor/coordinator.py @@ -0,0 +1,51 @@ +"""Coordinator for Guntamatic integration.""" + +from __future__ import annotations + +import logging + +from guntamatic.heater import Heater + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class GuntamaticCoordinator(DataUpdateCoordinator[dict[str, list[str]]]): + """Guntamatic data coordinator.""" + + def __init__(self, hass: HomeAssistant, heater: Heater) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + logger=_LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self.heater = heater + + async def _async_update_data(self) -> dict[str, list[str]]: + """Fetch data from heater. + + Expected return format: + { + "Boiler Temperature": [68.5, "°C"], + "Flue Temperature": [115.2, "°C"], + "Power Output": [12.4, "kW"], + } + + """ + try: + data: dict[str, list[str]] = await self.hass.async_add_executor_job( + self.heater.get_data + ) + except Exception as err: + raise UpdateFailed(f"Error communicating with heater: {err}") from err + if not data: + raise UpdateFailed("No data received from heater") + if not data.get("Serial"): + raise UpdateFailed("Could not get serial number from heater") + return data diff --git a/homeassistant/components/guntamatic_sensor/diagnostics.py b/homeassistant/components/guntamatic_sensor/diagnostics.py deleted file mode 100644 index 171bfd6ef2118..0000000000000 --- a/homeassistant/components/guntamatic_sensor/diagnostics.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Diagnostics support for Guntamatic.""" - -from __future__ import annotations - -from typing import Any - -from homeassistant.core import HomeAssistant - -from . import GuntamaticConfigEntry - - -async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: GuntamaticConfigEntry -) -> dict[str, Any]: - """Return diagnostics for a config entry.""" - return { - "entry_data": dict(entry.data), - "data": entry.runtime_data.coordinator.data, - } diff --git a/homeassistant/components/guntamatic_sensor/manifest.json b/homeassistant/components/guntamatic_sensor/manifest.json index 3637ec2ec76f2..fdbc3ce648b9e 100644 --- a/homeassistant/components/guntamatic_sensor/manifest.json +++ b/homeassistant/components/guntamatic_sensor/manifest.json @@ -4,6 +4,13 @@ "codeowners": ["@JensTimmerman"], "config_flow": true, "dependencies": [], + "dhcp": [ + { + "hostname": "kessel*", + "macaddress": "0024BD*" + } + ], + "documentation": "https://www.home-assistant.io/integrations/guntamatic_sensor", "homekit": {}, "integration_type": "device", diff --git a/homeassistant/components/guntamatic_sensor/quality_scale.yaml b/homeassistant/components/guntamatic_sensor/quality_scale.yaml index 5bf110fc2d029..00f51a0f92d3a 100644 --- a/homeassistant/components/guntamatic_sensor/quality_scale.yaml +++ b/homeassistant/components/guntamatic_sensor/quality_scale.yaml @@ -43,13 +43,9 @@ rules: # Gold devices: done - diagnostics: done - discovery-update-info: - status: exempt - comment: Guntamatic heaters do not support network discovery protocols. - discovery: - status: exempt - comment: Guntamatic heaters do not support network discovery protocols. + diagnostics: todo + discovery-update-info: done + discovery: done docs-data-update: todo docs-examples: todo docs-known-limitations: todo diff --git a/homeassistant/components/guntamatic_sensor/sensor.py b/homeassistant/components/guntamatic_sensor/sensor.py index 6ea994fde97a0..83838864ff22b 100644 --- a/homeassistant/components/guntamatic_sensor/sensor.py +++ b/homeassistant/components/guntamatic_sensor/sensor.py @@ -1,6 +1,13 @@ """Support for Guntamatic sensors in Home Assistant.""" -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, StateType +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, + StateType, +) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -10,22 +17,43 @@ DataUpdateCoordinator, ) -from . import GuntamaticConfigEntry -from .const import ( - DIAGNOSTIC_SENSORS, - DOMAIN, - SENSOR_DEVICE_CLASSES, - SENSOR_STATE_CLASSES, -) +from .const import DIAGNOSTIC_SENSORS, DOMAIN PARALLEL_UPDATES = 0 +# mapping from +UNIT_TO_DESCRIPTION: dict[str, SensorEntityDescription] = { + "°C": SensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="°C", + ), + "%": SensorEntityDescription( + key="percentage", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="%", + ), + "h": SensorEntityDescription( + key="duration_hours", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement="h", + ), + "d": SensorEntityDescription( + key="duration_days", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement="d", + ), +} + type GuntamaticCoordinator = DataUpdateCoordinator[dict[str, list[str]]] async def async_setup_entry( hass: HomeAssistant, - entry: GuntamaticConfigEntry, + entry: ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Guntamatic sensors from config entry.""" @@ -34,24 +62,19 @@ async def async_setup_entry( coordinator = data.coordinator heater = data.heater - known_sensors: set[str] = set() + # Create one entity per sensor + sensors = [ + GuntamaticSensor(coordinator, name, heater.host) for name in coordinator.data + ] - def _check_sensors() -> None: - current_sensors = set(coordinator.data) - new_sensors = current_sensors - known_sensors - if new_sensors: - known_sensors.update(new_sensors) - async_add_entities( - GuntamaticSensor(coordinator, name, heater.host) for name in new_sensors - ) - - _check_sensors() - entry.async_on_unload(coordinator.async_add_listener(_check_sensors)) + async_add_entities(sensors) class GuntamaticSensor(CoordinatorEntity[GuntamaticCoordinator], SensorEntity): """Representation of a single Guntamatic sensor.""" + _attr_has_entity_name = True + def __init__( self, coordinator: GuntamaticCoordinator, @@ -61,19 +84,15 @@ def __init__( """Initialize the sensor.""" super().__init__(coordinator) self._name = name - self._attr_has_entity_name = True self._attr_name = name - # serial might not be set on all devices - serial = coordinator.data.get("Serial", [None])[0] or host + serial = coordinator.data["Serial"][0] - self._attr_unique_id = ( - f"guntamatic_{serial.replace('.', '_')}_{name.replace(' ', '_')}" - ) + self._attr_unique_id = f"{serial.replace('.', '_')}_{name.replace(' ', '_')}" unit = coordinator.data[name][1] - self._attr_native_unit_of_measurement = unit - self._attr_device_class = SENSOR_DEVICE_CLASSES.get(name) - self._attr_state_class = SENSOR_STATE_CLASSES.get(name) + description = UNIT_TO_DESCRIPTION.get(unit) + if description is not None: + self.entity_description = description self._attr_entity_category = ( EntityCategory.DIAGNOSTIC if name in DIAGNOSTIC_SENSORS else None @@ -89,7 +108,7 @@ def __init__( identifiers={(DOMAIN, serial)}, name="Guntamatic Heater", manufacturer="Guntamatic", - serial_number=serial if serial != host else None, + serial_number=serial, sw_version=coordinator.data.get("Version", [None])[0], ) diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 1d2e1847c841a..b0d8a1ee6b027 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -293,6 +293,11 @@ "hostname": "guardian*", "macaddress": "30AEA4*", }, + { + "domain": "guntamatic_sensor", + "hostname": "kessel*", + "macaddress": "0024BD*", + }, { "domain": "home_connect", "hostname": "balay-*", diff --git a/tests/components/guntamatic_sensor/test_config_flow.py b/tests/components/guntamatic_sensor/test_config_flow.py index 32b7b3bc08f4e..c5fdac4676fed 100644 --- a/tests/components/guntamatic_sensor/test_config_flow.py +++ b/tests/components/guntamatic_sensor/test_config_flow.py @@ -9,6 +9,7 @@ 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 tests.common import MockConfigEntry @@ -145,3 +146,24 @@ async def test_form_already_configured( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_dhcp_discovery(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test DHCP discovery.""" + with patch( + "homeassistant.components.guntamatic_sensor.config_flow.Heater.get_data", + return_value={"Boiler temperature": ["14.09", "°C"]}, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="1.1.1.1", + hostname="kessel0001", + macaddress="0024bd123456", + ), + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_HOST: "1.1.1.1"} diff --git a/tests/components/guntamatic_sensor/test_coordinator.py b/tests/components/guntamatic_sensor/test_coordinator.py new file mode 100644 index 0000000000000..f7235170929d0 --- /dev/null +++ b/tests/components/guntamatic_sensor/test_coordinator.py @@ -0,0 +1,46 @@ +"""Test the coordinator.""" + +from unittest.mock import MagicMock + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_coordinator_update_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_heater: MagicMock, +) -> None: + """Test coordinator handles update failure.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_heater.return_value.get_data.side_effect = Exception("Connection lost") + await mock_config_entry.runtime_data.coordinator.async_refresh() + await hass.async_block_till_done() + + state = hass.states.get("sensor.guntamatic_heater_boiler_temperature") + assert state.state == STATE_UNAVAILABLE + + +async def test_coordinator_empty_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_heater: MagicMock, +) -> None: + """Test coordinator handles empty data.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_heater.return_value.get_data.return_value = {} + await mock_config_entry.runtime_data.coordinator.async_refresh() + await hass.async_block_till_done() + + state = hass.states.get("sensor.guntamatic_heater_boiler_temperature") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/guntamatic_sensor/test_diagnostics.py b/tests/components/guntamatic_sensor/test_diagnostics.py deleted file mode 100644 index 3631895a1a97e..0000000000000 --- a/tests/components/guntamatic_sensor/test_diagnostics.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Test the Guntamatic diagnostics.""" - -from unittest.mock import MagicMock - -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry -from tests.components.diagnostics import get_diagnostics_for_config_entry -from tests.typing import ClientSessionGenerator - - -async def test_diagnostics( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_config_entry: MockConfigEntry, - mock_heater: MagicMock, -) -> None: - """Test diagnostics.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - result = await get_diagnostics_for_config_entry( - hass, hass_client, mock_config_entry - ) - - assert result["entry_data"] == {"host": "1.1.1.1"} - assert result["data"] == mock_heater.return_value.get_data.return_value From 1c3e485f6781ae50bb60d808f6c93e539ac1a6a5 Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Mon, 6 Apr 2026 01:01:22 +0200 Subject: [PATCH 04/43] Update tests/components/guntamatic_sensor/conftest.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/components/guntamatic_sensor/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/guntamatic_sensor/conftest.py b/tests/components/guntamatic_sensor/conftest.py index f448336095a2b..8a4a2097bc0b4 100644 --- a/tests/components/guntamatic_sensor/conftest.py +++ b/tests/components/guntamatic_sensor/conftest.py @@ -23,7 +23,7 @@ @pytest.fixture -def mock_heater() -> Generator[AsyncMock]: +def mock_heater() -> Generator[MagicMock]: """Mock the Heater class.""" with patch( "homeassistant.components.guntamatic_sensor.Heater", From 8499ddd45a1189c1a5e47913c48e1e99073a4168 Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Mon, 6 Apr 2026 01:03:22 +0200 Subject: [PATCH 05/43] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/guntamatic_sensor/sensor.py | 2 +- homeassistant/components/guntamatic_sensor/strings.json | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/guntamatic_sensor/sensor.py b/homeassistant/components/guntamatic_sensor/sensor.py index 83838864ff22b..750eb3e875a46 100644 --- a/homeassistant/components/guntamatic_sensor/sensor.py +++ b/homeassistant/components/guntamatic_sensor/sensor.py @@ -21,7 +21,7 @@ PARALLEL_UPDATES = 0 -# mapping from +# Mapping from unit of measurement to sensor description. UNIT_TO_DESCRIPTION: dict[str, SensorEntityDescription] = { "°C": SensorEntityDescription( key="temperature", diff --git a/homeassistant/components/guntamatic_sensor/strings.json b/homeassistant/components/guntamatic_sensor/strings.json index 58e6706335341..9688f25f8a90c 100644 --- a/homeassistant/components/guntamatic_sensor/strings.json +++ b/homeassistant/components/guntamatic_sensor/strings.json @@ -6,7 +6,6 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { From d6ff64c840de99538f49b034c261991b4492cd7c Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Mon, 6 Apr 2026 01:08:11 +0200 Subject: [PATCH 06/43] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/components/guntamatic_sensor/test_config_flow.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/components/guntamatic_sensor/test_config_flow.py b/tests/components/guntamatic_sensor/test_config_flow.py index c5fdac4676fed..84135d1fb9b86 100644 --- a/tests/components/guntamatic_sensor/test_config_flow.py +++ b/tests/components/guntamatic_sensor/test_config_flow.py @@ -148,11 +148,15 @@ async def test_form_already_configured( assert result["reason"] == "already_configured" -async def test_dhcp_discovery(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: +async def test_dhcp_discovery( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_data: dict[str, list[str]], +) -> None: """Test DHCP discovery.""" with patch( "homeassistant.components.guntamatic_sensor.config_flow.Heater.get_data", - return_value={"Boiler temperature": ["14.09", "°C"]}, + return_value=mock_data, ): result = await hass.config_entries.flow.async_init( DOMAIN, From f08bddb9995e6ba41bb26030fe71d4ffcf4adcf9 Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Mon, 6 Apr 2026 01:23:40 +0200 Subject: [PATCH 07/43] Addresses remarks in pr and fix tests --- .../components/guntamatic_sensor/__init__.py | 18 +++++------------- .../components/guntamatic_sensor/const.py | 14 -------------- .../guntamatic_sensor/test_config_flow.py | 8 +++----- 3 files changed, 8 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/guntamatic_sensor/__init__.py b/homeassistant/components/guntamatic_sensor/__init__.py index 632d1d6e82e76..8c8034bd2d267 100644 --- a/homeassistant/components/guntamatic_sensor/__init__.py +++ b/homeassistant/components/guntamatic_sensor/__init__.py @@ -34,22 +34,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: GuntamaticConfigEntry) - host = entry.data[CONF_HOST] heater = Heater(host) - # initial connectivity check - initial_data = None - try: - initial_data = await hass.async_add_executor_job(heater.get_data) - except Exception as err: - raise ConfigEntryNotReady( - f"Cannot connect to Guntamatic heater: {err}" - ) from err - - if not initial_data: - raise ConfigEntryNotReady("Cannot connect to Guntamatic heater") - coordinator = GuntamaticCoordinator(hass, heater) coordinator.config_entry = entry - coordinator.data = initial_data + try: + # Fetch initial data + await coordinator.async_config_entry_first_refresh() + except Exception as err: + raise ConfigEntryNotReady(f"Error while connecting to {host}: {err}") from err entry.runtime_data = GuntamaticData(heater=heater, coordinator=coordinator) await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) diff --git a/homeassistant/components/guntamatic_sensor/const.py b/homeassistant/components/guntamatic_sensor/const.py index da67363853d63..2aa13125bec92 100644 --- a/homeassistant/components/guntamatic_sensor/const.py +++ b/homeassistant/components/guntamatic_sensor/const.py @@ -2,20 +2,6 @@ from datetime import timedelta -from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass - DOMAIN = "guntamatic_sensor" SCAN_INTERVAL = timedelta(seconds=30) DIAGNOSTIC_SENSORS = {"Serial", "Version", "Operat. time", "Service Hrs"} - - -UNIT_TO_DEVICE_CLASS: dict[str, SensorDeviceClass] = { - "°C": SensorDeviceClass.TEMPERATURE, - "h": SensorDeviceClass.DURATION, - "d": SensorDeviceClass.DURATION, -} - -UNIT_TO_STATE_CLASS: dict[str, SensorStateClass] = { - "°C": SensorStateClass.MEASUREMENT, - "%": SensorStateClass.MEASUREMENT, -} diff --git a/tests/components/guntamatic_sensor/test_config_flow.py b/tests/components/guntamatic_sensor/test_config_flow.py index 84135d1fb9b86..c4b72328b73db 100644 --- a/tests/components/guntamatic_sensor/test_config_flow.py +++ b/tests/components/guntamatic_sensor/test_config_flow.py @@ -1,6 +1,6 @@ """Test the guntamatic config flow.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import requests @@ -149,14 +149,12 @@ async def test_form_already_configured( async def test_dhcp_discovery( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - mock_data: dict[str, list[str]], + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_heater: MagicMock ) -> None: """Test DHCP discovery.""" with patch( "homeassistant.components.guntamatic_sensor.config_flow.Heater.get_data", - return_value=mock_data, + return_value=mock_heater, ): result = await hass.config_entries.flow.async_init( DOMAIN, From 1e06298fe9183de6dfbeef58894b872e95ffeee9 Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Mon, 6 Apr 2026 01:43:30 +0200 Subject: [PATCH 08/43] explicitly pass config entry --- homeassistant/components/guntamatic_sensor/__init__.py | 9 +++------ .../components/guntamatic_sensor/coordinator.py | 4 +++- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/guntamatic_sensor/__init__.py b/homeassistant/components/guntamatic_sensor/__init__.py index 8c8034bd2d267..54799310794d5 100644 --- a/homeassistant/components/guntamatic_sensor/__init__.py +++ b/homeassistant/components/guntamatic_sensor/__init__.py @@ -17,8 +17,6 @@ _LOGGER = logging.getLogger(__name__) _PLATFORMS: list[Platform] = [Platform.SENSOR] -type GuntamaticConfigEntry = ConfigEntry[GuntamaticData] - @dataclass class GuntamaticData: @@ -28,14 +26,13 @@ class GuntamaticData: coordinator: GuntamaticCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: GuntamaticConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up guntamatic from a config entry.""" host = entry.data[CONF_HOST] heater = Heater(host) - coordinator = GuntamaticCoordinator(hass, heater) - coordinator.config_entry = entry + coordinator = GuntamaticCoordinator(hass, heater, entry) try: # Fetch initial data @@ -48,6 +45,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: GuntamaticConfigEntry) - return True -async def async_unload_entry(hass: HomeAssistant, entry: GuntamaticConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/guntamatic_sensor/coordinator.py b/homeassistant/components/guntamatic_sensor/coordinator.py index 2e1b96f32d4be..219bbcbdf0368 100644 --- a/homeassistant/components/guntamatic_sensor/coordinator.py +++ b/homeassistant/components/guntamatic_sensor/coordinator.py @@ -6,6 +6,7 @@ from guntamatic.heater import Heater +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -17,13 +18,14 @@ class GuntamaticCoordinator(DataUpdateCoordinator[dict[str, list[str]]]): """Guntamatic data coordinator.""" - def __init__(self, hass: HomeAssistant, heater: Heater) -> None: + def __init__(self, hass: HomeAssistant, heater: Heater, entry: ConfigEntry) -> None: """Initialize coordinator.""" super().__init__( hass, logger=_LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL, + config_entry=entry, ) self.heater = heater From c3ebb1a5ec838d8383e39e72859c6be606937024 Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Sun, 12 Apr 2026 19:23:42 +0200 Subject: [PATCH 09/43] No more dynamic sensors, hardcode sensorentity descriptions --- .../components/guntamatic_sensor/__init__.py | 11 +- .../guntamatic_sensor/coordinator.py | 2 +- .../guntamatic_sensor/manifest.json | 2 +- .../guntamatic_sensor/quality_scale.yaml | 4 +- .../components/guntamatic_sensor/sensor.py | 117 +++++++++++------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/guntamatic_sensor/conftest.py | 10 +- .../guntamatic_sensor/test_coordinator.py | 8 +- .../components/guntamatic_sensor/test_init.py | 4 +- .../guntamatic_sensor/test_sensor.py | 2 +- 11 files changed, 94 insertions(+), 70 deletions(-) diff --git a/homeassistant/components/guntamatic_sensor/__init__.py b/homeassistant/components/guntamatic_sensor/__init__.py index 54799310794d5..3240134d691c9 100644 --- a/homeassistant/components/guntamatic_sensor/__init__.py +++ b/homeassistant/components/guntamatic_sensor/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -from dataclasses import dataclass import logging from guntamatic.heater import Heater @@ -18,14 +17,6 @@ _PLATFORMS: list[Platform] = [Platform.SENSOR] -@dataclass -class GuntamaticData: - """Data for the Guntamatic integration.""" - - heater: Heater - coordinator: GuntamaticCoordinator - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up guntamatic from a config entry.""" @@ -39,7 +30,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() except Exception as err: raise ConfigEntryNotReady(f"Error while connecting to {host}: {err}") from err - entry.runtime_data = GuntamaticData(heater=heater, coordinator=coordinator) + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) return True diff --git a/homeassistant/components/guntamatic_sensor/coordinator.py b/homeassistant/components/guntamatic_sensor/coordinator.py index 219bbcbdf0368..9d50f5ade445b 100644 --- a/homeassistant/components/guntamatic_sensor/coordinator.py +++ b/homeassistant/components/guntamatic_sensor/coordinator.py @@ -42,7 +42,7 @@ async def _async_update_data(self) -> dict[str, list[str]]: """ try: data: dict[str, list[str]] = await self.hass.async_add_executor_job( - self.heater.get_data + self.heater.parse_data ) except Exception as err: raise UpdateFailed(f"Error communicating with heater: {err}") from err diff --git a/homeassistant/components/guntamatic_sensor/manifest.json b/homeassistant/components/guntamatic_sensor/manifest.json index fdbc3ce648b9e..daa95901b1dde 100644 --- a/homeassistant/components/guntamatic_sensor/manifest.json +++ b/homeassistant/components/guntamatic_sensor/manifest.json @@ -16,7 +16,7 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["guntamatic==1.0.3"], + "requirements": ["guntamatic==1.1.0"], "ssdp": [], "zeroconf": [] } diff --git a/homeassistant/components/guntamatic_sensor/quality_scale.yaml b/homeassistant/components/guntamatic_sensor/quality_scale.yaml index 00f51a0f92d3a..14d731fbf14cf 100644 --- a/homeassistant/components/guntamatic_sensor/quality_scale.yaml +++ b/homeassistant/components/guntamatic_sensor/quality_scale.yaml @@ -44,8 +44,8 @@ rules: # Gold devices: done diagnostics: todo - discovery-update-info: done - discovery: done + discovery-update-info: todo + discovery: todo docs-data-update: todo docs-examples: todo docs-known-limitations: todo diff --git a/homeassistant/components/guntamatic_sensor/sensor.py b/homeassistant/components/guntamatic_sensor/sensor.py index 750eb3e875a46..70193412bbc22 100644 --- a/homeassistant/components/guntamatic_sensor/sensor.py +++ b/homeassistant/components/guntamatic_sensor/sensor.py @@ -8,7 +8,6 @@ StateType, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -17,36 +16,82 @@ DataUpdateCoordinator, ) -from .const import DIAGNOSTIC_SENSORS, DOMAIN +from .const import DOMAIN PARALLEL_UPDATES = 0 # Mapping from unit of measurement to sensor description. -UNIT_TO_DESCRIPTION: dict[str, SensorEntityDescription] = { - "°C": SensorEntityDescription( - key="temperature", +GUNTAMATIC_SENSORS: list[SensorEntityDescription] = [ + SensorEntityDescription( + key="Status", + device_class=SensorDeviceClass.ENUM, + ), + SensorEntityDescription( + key="Program", + device_class=SensorDeviceClass.ENUM, + ), + SensorEntityDescription( + key="Serial", + device_class=SensorDeviceClass.ENUM, + ), + SensorEntityDescription( + key="Version", + device_class=SensorDeviceClass.ENUM, + ), + SensorEntityDescription( + key="Boiler Temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement="°C", ), - "%": SensorEntityDescription( - key="percentage", + SensorEntityDescription( + key="Outdoor Temperature", + device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement="%", + native_unit_of_measurement="°C", + ), + SensorEntityDescription( + key="Buffer Top Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="°C", ), - "h": SensorEntityDescription( - key="duration_hours", - device_class=SensorDeviceClass.DURATION, - state_class=SensorStateClass.TOTAL_INCREASING, - native_unit_of_measurement="h", + SensorEntityDescription( + key="Buffer Center Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="°C", ), - "d": SensorEntityDescription( - key="duration_days", - device_class=SensorDeviceClass.DURATION, - state_class=SensorStateClass.TOTAL_INCREASING, - native_unit_of_measurement="d", + SensorEntityDescription( + key="Buffer Bottom Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="°C", ), -} + SensorEntityDescription( + key="Domestic Home Water Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="°C", + ), + SensorEntityDescription( + key="Room 1 Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="°C", + ), + SensorEntityDescription( + key="Room 2 Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="°C", + ), + SensorEntityDescription( + key="Buffer Load", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="%", + ), +] type GuntamaticCoordinator = DataUpdateCoordinator[dict[str, list[str]]] @@ -58,13 +103,12 @@ async def async_setup_entry( ) -> None: """Set up Guntamatic sensors from config entry.""" - data = entry.runtime_data - coordinator = data.coordinator - heater = data.heater + coordinator = entry.runtime_data - # Create one entity per sensor sensors = [ - GuntamaticSensor(coordinator, name, heater.host) for name in coordinator.data + GuntamaticSensor(coordinator, description) + for description in GUNTAMATIC_SENSORS + if description.key in coordinator.data ] async_add_entities(sensors) @@ -78,32 +122,21 @@ class GuntamaticSensor(CoordinatorEntity[GuntamaticCoordinator], SensorEntity): def __init__( self, coordinator: GuntamaticCoordinator, - name: str, - host: str, + entitydescription: SensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - self._name = name - self._attr_name = name - serial = coordinator.data["Serial"][0] + self.entity_description = entitydescription - self._attr_unique_id = f"{serial.replace('.', '_')}_{name.replace(' ', '_')}" + self._name = entitydescription.key + self._attr_name = entitydescription.key - unit = coordinator.data[name][1] - description = UNIT_TO_DESCRIPTION.get(unit) - if description is not None: - self.entity_description = description + serial = coordinator.data["Serial"][0] - self._attr_entity_category = ( - EntityCategory.DIAGNOSTIC if name in DIAGNOSTIC_SENSORS else None + self._attr_unique_id = ( + f"{serial.replace('.', '_')}_{entitydescription.key.replace(' ', '_')}" ) - # if no unit is given by the guntamatic, it's a string, so set None - if not unit: - self._attr_native_unit_of_measurement = None - self._attr_state_class = None - self._attr_device_class = SensorDeviceClass.ENUM - self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, serial)}, name="Guntamatic Heater", diff --git a/requirements_all.txt b/requirements_all.txt index bd4469a4d8d01..fcf2c6e464e53 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1155,7 +1155,7 @@ growattServer==1.9.0 gspread==5.5.0 # homeassistant.components.guntamatic_sensor -guntamatic==1.0.3 +guntamatic==1.1.0 # homeassistant.components.profiler guppy3==3.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 377b9134a0f5d..376c6c3a6608d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1028,7 +1028,7 @@ growattServer==1.9.0 gspread==5.5.0 # homeassistant.components.guntamatic_sensor -guntamatic==1.0.3 +guntamatic==1.1.0 # homeassistant.components.profiler guppy3==3.1.6 diff --git a/tests/components/guntamatic_sensor/conftest.py b/tests/components/guntamatic_sensor/conftest.py index 8a4a2097bc0b4..3f6eaf8981a89 100644 --- a/tests/components/guntamatic_sensor/conftest.py +++ b/tests/components/guntamatic_sensor/conftest.py @@ -12,11 +12,11 @@ from tests.common import MockConfigEntry MOCK_DATA = { - "Boiler temperature": ["14.09", "°C"], - "Outside Temp.": ["15.95", "°C"], - "Buffer load.": ["22", "%"], + "Boiler Temperature": ["14.09", "°C"], + "Outdoor Temperature": ["15.95", "°C"], + "Buffer Load": ["22", "%"], "Program": ["HEAT", ""], - "Running": ["Service Ign.", ""], + "Status": ["Service Ign.", ""], "Serial": ["959103", ""], "Version": ["32a", ""], } @@ -29,7 +29,7 @@ def mock_heater() -> Generator[MagicMock]: "homeassistant.components.guntamatic_sensor.Heater", autospec=True, ) as mock: - mock.return_value.get_data = MagicMock(return_value=MOCK_DATA) + mock.return_value.parse_data = MagicMock(return_value=MOCK_DATA) mock.return_value.host = "1.1.1.1" yield mock diff --git a/tests/components/guntamatic_sensor/test_coordinator.py b/tests/components/guntamatic_sensor/test_coordinator.py index f7235170929d0..0a6de9d202004 100644 --- a/tests/components/guntamatic_sensor/test_coordinator.py +++ b/tests/components/guntamatic_sensor/test_coordinator.py @@ -20,8 +20,8 @@ async def test_coordinator_update_failed( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.LOADED - mock_heater.return_value.get_data.side_effect = Exception("Connection lost") - await mock_config_entry.runtime_data.coordinator.async_refresh() + mock_heater.return_value.parse_data.side_effect = Exception("Connection lost") + await mock_config_entry.runtime_data.async_refresh() await hass.async_block_till_done() state = hass.states.get("sensor.guntamatic_heater_boiler_temperature") @@ -38,8 +38,8 @@ async def test_coordinator_empty_data( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - mock_heater.return_value.get_data.return_value = {} - await mock_config_entry.runtime_data.coordinator.async_refresh() + mock_heater.return_value.parse_data.return_value = {} + await mock_config_entry.runtime_data.async_refresh() await hass.async_block_till_done() state = hass.states.get("sensor.guntamatic_heater_boiler_temperature") diff --git a/tests/components/guntamatic_sensor/test_init.py b/tests/components/guntamatic_sensor/test_init.py index 12d3d046054b1..60ddede030d53 100644 --- a/tests/components/guntamatic_sensor/test_init.py +++ b/tests/components/guntamatic_sensor/test_init.py @@ -26,7 +26,7 @@ async def test_setup_entry_cannot_connect( mock_heater: MagicMock, ) -> None: """Test setup fails when heater is unreachable.""" - mock_heater.return_value.get_data.side_effect = Exception("Cannot connect") + mock_heater.return_value.parse_data.side_effect = Exception("Cannot connect") mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -53,7 +53,7 @@ async def test_setup_entry_empty_data( mock_heater: MagicMock, ) -> None: """Test setup fails when heater returns no data.""" - mock_heater.return_value.get_data.return_value = {} + mock_heater.return_value.parse_data.return_value = {} mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/guntamatic_sensor/test_sensor.py b/tests/components/guntamatic_sensor/test_sensor.py index faa98b7f5a8bf..b8c1a60e665f9 100644 --- a/tests/components/guntamatic_sensor/test_sensor.py +++ b/tests/components/guntamatic_sensor/test_sensor.py @@ -33,6 +33,6 @@ async def test_sensor_native_value( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("sensor.guntamatic_heater_outside_temp") + state = hass.states.get("sensor.guntamatic_heater_outdoor_temperature") assert state is not None assert state.state == "15.95" From a422c0bbefd5f71726b898da4103e50cd3c64864 Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Sun, 12 Apr 2026 20:19:37 +0200 Subject: [PATCH 10/43] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/guntamatic_sensor/config_flow.py | 4 ++-- homeassistant/components/guntamatic_sensor/sensor.py | 12 +++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/guntamatic_sensor/config_flow.py b/homeassistant/components/guntamatic_sensor/config_flow.py index da6e76790a6d8..c6688631841b9 100644 --- a/homeassistant/components/guntamatic_sensor/config_flow.py +++ b/homeassistant/components/guntamatic_sensor/config_flow.py @@ -52,14 +52,14 @@ async def async_step_user( self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) try: heater = Heater(user_input[CONF_HOST]) - data = await self.hass.async_add_executor_job(heater.get_data) + data = await self.hass.async_add_executor_job(heater.parse_data) except requests.exceptions.ConnectionError: errors["base"] = "cannot_connect" except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - if not data: + if not data or not data.get("Serial"): errors["base"] = "cannot_connect" else: return self.async_create_entry( diff --git a/homeassistant/components/guntamatic_sensor/sensor.py b/homeassistant/components/guntamatic_sensor/sensor.py index 70193412bbc22..171bb68a3a1f8 100644 --- a/homeassistant/components/guntamatic_sensor/sensor.py +++ b/homeassistant/components/guntamatic_sensor/sensor.py @@ -148,8 +148,18 @@ def __init__( @property def native_value(self) -> StateType: """Return the current value of the sensor.""" - return self.coordinator.data[self._name][0] + value = self.coordinator.data[self._name][0] + if value is None: + return None + + if self.entity_description.state_class == SensorStateClass.MEASUREMENT: + try: + return float(value) + except (TypeError, ValueError): + return value + + return value @property def available(self) -> bool: """Return True if entity is available.""" From 8b9235bf27a24d5950efd38fc0b56071a9a64881 Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Sun, 12 Apr 2026 20:46:39 +0200 Subject: [PATCH 11/43] moved guntamatic_sensor to guntamatic --- .strict-typing | 2 +- CODEOWNERS | 4 +- .../__init__.py | 0 .../config_flow.py | 0 .../const.py | 0 .../coordinator.py | 0 .../manifest.json | 7 +- .../quality_scale.yaml | 0 .../sensor.py | 67 +++++++++---------- .../strings.json | 0 homeassistant/generated/config_flows.py | 2 +- homeassistant/generated/dhcp.py | 2 +- homeassistant/generated/integrations.json | 4 +- mypy.ini | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../__init__.py | 0 .../conftest.py | 6 +- .../test_config_flow.py | 16 ++--- .../test_coordinator.py | 0 .../test_init.py | 0 .../test_sensor.py | 0 22 files changed, 56 insertions(+), 60 deletions(-) rename homeassistant/components/{guntamatic_sensor => guntamatic}/__init__.py (100%) rename homeassistant/components/{guntamatic_sensor => guntamatic}/config_flow.py (100%) rename homeassistant/components/{guntamatic_sensor => guntamatic}/const.py (100%) rename homeassistant/components/{guntamatic_sensor => guntamatic}/coordinator.py (100%) rename homeassistant/components/{guntamatic_sensor => guntamatic}/manifest.json (81%) rename homeassistant/components/{guntamatic_sensor => guntamatic}/quality_scale.yaml (100%) rename homeassistant/components/{guntamatic_sensor => guntamatic}/sensor.py (71%) rename homeassistant/components/{guntamatic_sensor => guntamatic}/strings.json (100%) rename tests/components/{guntamatic_sensor => guntamatic}/__init__.py (100%) rename tests/components/{guntamatic_sensor => guntamatic}/conftest.py (86%) rename tests/components/{guntamatic_sensor => guntamatic}/test_config_flow.py (88%) rename tests/components/{guntamatic_sensor => guntamatic}/test_coordinator.py (100%) rename tests/components/{guntamatic_sensor => guntamatic}/test_init.py (100%) rename tests/components/{guntamatic_sensor => guntamatic}/test_sensor.py (100%) diff --git a/.strict-typing b/.strict-typing index a34eb8d2cef2f..c03a712c724be 100644 --- a/.strict-typing +++ b/.strict-typing @@ -245,7 +245,7 @@ homeassistant.components.gpsd.* homeassistant.components.greeneye_monitor.* homeassistant.components.group.* homeassistant.components.guardian.* -homeassistant.components.guntamatic_sensor.* +homeassistant.components.guntamatic.* homeassistant.components.habitica.* homeassistant.components.hardkernel.* homeassistant.components.hardware.* diff --git a/CODEOWNERS b/CODEOWNERS index 48fdfb42e1357..39f5c15552344 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -676,8 +676,8 @@ CLAUDE.md @home-assistant/core /tests/components/growatt_server/ @johanzander /homeassistant/components/guardian/ @bachya /tests/components/guardian/ @bachya -/homeassistant/components/guntamatic_sensor/ @JensTimmerman -/tests/components/guntamatic_sensor/ @JensTimmerman +/homeassistant/components/guntamatic/ @JensTimmerman +/tests/components/guntamatic/ @JensTimmerman /homeassistant/components/habitica/ @tr4nt0r /tests/components/habitica/ @tr4nt0r /homeassistant/components/hanna/ @bestycame diff --git a/homeassistant/components/guntamatic_sensor/__init__.py b/homeassistant/components/guntamatic/__init__.py similarity index 100% rename from homeassistant/components/guntamatic_sensor/__init__.py rename to homeassistant/components/guntamatic/__init__.py diff --git a/homeassistant/components/guntamatic_sensor/config_flow.py b/homeassistant/components/guntamatic/config_flow.py similarity index 100% rename from homeassistant/components/guntamatic_sensor/config_flow.py rename to homeassistant/components/guntamatic/config_flow.py diff --git a/homeassistant/components/guntamatic_sensor/const.py b/homeassistant/components/guntamatic/const.py similarity index 100% rename from homeassistant/components/guntamatic_sensor/const.py rename to homeassistant/components/guntamatic/const.py diff --git a/homeassistant/components/guntamatic_sensor/coordinator.py b/homeassistant/components/guntamatic/coordinator.py similarity index 100% rename from homeassistant/components/guntamatic_sensor/coordinator.py rename to homeassistant/components/guntamatic/coordinator.py diff --git a/homeassistant/components/guntamatic_sensor/manifest.json b/homeassistant/components/guntamatic/manifest.json similarity index 81% rename from homeassistant/components/guntamatic_sensor/manifest.json rename to homeassistant/components/guntamatic/manifest.json index daa95901b1dde..289a8e0c4eaac 100644 --- a/homeassistant/components/guntamatic_sensor/manifest.json +++ b/homeassistant/components/guntamatic/manifest.json @@ -1,6 +1,6 @@ { - "domain": "guntamatic_sensor", - "name": "guntamatic", + "domain": "guntamatic", + "name": "Guntamatic", "codeowners": ["@JensTimmerman"], "config_flow": true, "dependencies": [], @@ -11,8 +11,7 @@ } ], - "documentation": "https://www.home-assistant.io/integrations/guntamatic_sensor", - "homekit": {}, + "documentation": "https://www.home-assistant.io/integrations/guntamatic", "integration_type": "device", "iot_class": "local_polling", "quality_scale": "silver", diff --git a/homeassistant/components/guntamatic_sensor/quality_scale.yaml b/homeassistant/components/guntamatic/quality_scale.yaml similarity index 100% rename from homeassistant/components/guntamatic_sensor/quality_scale.yaml rename to homeassistant/components/guntamatic/quality_scale.yaml diff --git a/homeassistant/components/guntamatic_sensor/sensor.py b/homeassistant/components/guntamatic/sensor.py similarity index 71% rename from homeassistant/components/guntamatic_sensor/sensor.py rename to homeassistant/components/guntamatic/sensor.py index 171bb68a3a1f8..72ebb1aaedc4a 100644 --- a/homeassistant/components/guntamatic_sensor/sensor.py +++ b/homeassistant/components/guntamatic/sensor.py @@ -8,15 +8,14 @@ StateType, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, EntityCategory, 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, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import GuntamaticCoordinator PARALLEL_UPDATES = 0 @@ -24,77 +23,78 @@ GUNTAMATIC_SENSORS: list[SensorEntityDescription] = [ SensorEntityDescription( key="Status", - device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, ), SensorEntityDescription( key="Program", device_class=SensorDeviceClass.ENUM, + options=[ + "OFF", + "TIMER", + "DHW", + "HEAT", + "HIBERNAT", + "HIBERNATE TO", + "DHW BOOST", + ], ), - SensorEntityDescription( - key="Serial", - device_class=SensorDeviceClass.ENUM, - ), - SensorEntityDescription( - key="Version", - device_class=SensorDeviceClass.ENUM, - ), + SensorEntityDescription(key="Serial", entity_category=EntityCategory.DIAGNOSTIC), + SensorEntityDescription(key="Version", entity_category=EntityCategory.DIAGNOSTIC), SensorEntityDescription( key="Boiler Temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement="°C", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), SensorEntityDescription( key="Outdoor Temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement="°C", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), SensorEntityDescription( key="Buffer Top Temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement="°C", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), SensorEntityDescription( key="Buffer Center Temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement="°C", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), SensorEntityDescription( key="Buffer Bottom Temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement="°C", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), SensorEntityDescription( key="Domestic Home Water Temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement="°C", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), SensorEntityDescription( key="Room 1 Temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement="°C", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), SensorEntityDescription( key="Room 2 Temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement="°C", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), SensorEntityDescription( key="Buffer Load", state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement="%", + native_unit_of_measurement=PERCENTAGE, ), ] -type GuntamaticCoordinator = DataUpdateCoordinator[dict[str, list[str]]] - async def async_setup_entry( hass: HomeAssistant, @@ -122,19 +122,19 @@ class GuntamaticSensor(CoordinatorEntity[GuntamaticCoordinator], SensorEntity): def __init__( self, coordinator: GuntamaticCoordinator, - entitydescription: SensorEntityDescription, + entity_description: SensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - self.entity_description = entitydescription + self.entity_description = entity_description - self._name = entitydescription.key - self._attr_name = entitydescription.key + self._name = entity_description.key + self._attr_name = entity_description.key serial = coordinator.data["Serial"][0] self._attr_unique_id = ( - f"{serial.replace('.', '_')}_{entitydescription.key.replace(' ', '_')}" + f"{serial.replace('.', '_')}_{entity_description.key.replace(' ', '_')}" ) self._attr_device_info = DeviceInfo( @@ -142,7 +142,7 @@ def __init__( name="Guntamatic Heater", manufacturer="Guntamatic", serial_number=serial, - sw_version=coordinator.data.get("Version", [None])[0], + sw_version=coordinator.data.get("Version", [""])[0] or None, ) @property @@ -150,16 +150,13 @@ def native_value(self) -> StateType: """Return the current value of the sensor.""" value = self.coordinator.data[self._name][0] - if value is None: - return None - if self.entity_description.state_class == SensorStateClass.MEASUREMENT: try: return float(value) - except (TypeError, ValueError): + except TypeError, ValueError: return value - return value + @property def available(self) -> bool: """Return True if entity is available.""" diff --git a/homeassistant/components/guntamatic_sensor/strings.json b/homeassistant/components/guntamatic/strings.json similarity index 100% rename from homeassistant/components/guntamatic_sensor/strings.json rename to homeassistant/components/guntamatic/strings.json diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d66d298f9a859..38ab91df0eed4 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -282,7 +282,7 @@ "green_planet_energy", "growatt_server", "guardian", - "guntamatic_sensor", + "guntamatic", "habitica", "hanna", "harmony", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index b0d8a1ee6b027..92d4d626eb171 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -294,7 +294,7 @@ "macaddress": "30AEA4*", }, { - "domain": "guntamatic_sensor", + "domain": "guntamatic", "hostname": "kessel*", "macaddress": "0024BD*", }, diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 4749bf2d7e315..329d1298c0915 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2683,8 +2683,8 @@ "config_flow": true, "iot_class": "local_polling" }, - "guntamatic_sensor": { - "name": "guntamatic", + "guntamatic": { + "name": "Guntamatic", "integration_type": "device", "config_flow": true, "iot_class": "local_polling" diff --git a/mypy.ini b/mypy.ini index 3597eb0c5bf81..48267afe55064 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2205,7 +2205,7 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true -[mypy-homeassistant.components.guntamatic_sensor.*] +[mypy-homeassistant.components.guntamatic.*] check_untyped_defs = true disallow_incomplete_defs = true disallow_subclassing_any = true diff --git a/requirements_all.txt b/requirements_all.txt index fcf2c6e464e53..21aa688a32893 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1154,7 +1154,7 @@ growattServer==1.9.0 # homeassistant.components.google_sheets gspread==5.5.0 -# homeassistant.components.guntamatic_sensor +# homeassistant.components.guntamatic guntamatic==1.1.0 # homeassistant.components.profiler diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 376c6c3a6608d..34e88e5846a5b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1027,7 +1027,7 @@ growattServer==1.9.0 # homeassistant.components.google_sheets gspread==5.5.0 -# homeassistant.components.guntamatic_sensor +# homeassistant.components.guntamatic guntamatic==1.1.0 # homeassistant.components.profiler diff --git a/tests/components/guntamatic_sensor/__init__.py b/tests/components/guntamatic/__init__.py similarity index 100% rename from tests/components/guntamatic_sensor/__init__.py rename to tests/components/guntamatic/__init__.py diff --git a/tests/components/guntamatic_sensor/conftest.py b/tests/components/guntamatic/conftest.py similarity index 86% rename from tests/components/guntamatic_sensor/conftest.py rename to tests/components/guntamatic/conftest.py index 3f6eaf8981a89..d1e11538f7adc 100644 --- a/tests/components/guntamatic_sensor/conftest.py +++ b/tests/components/guntamatic/conftest.py @@ -5,7 +5,7 @@ import pytest -from homeassistant.components.guntamatic_sensor.const import DOMAIN +from homeassistant.components.guntamatic.const import DOMAIN from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant @@ -26,7 +26,7 @@ def mock_heater() -> Generator[MagicMock]: """Mock the Heater class.""" with patch( - "homeassistant.components.guntamatic_sensor.Heater", + "homeassistant.components.guntamatic.Heater", autospec=True, ) as mock: mock.return_value.parse_data = MagicMock(return_value=MOCK_DATA) @@ -47,7 +47,7 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( - "homeassistant.components.guntamatic_sensor.async_setup_entry", + "homeassistant.components.guntamatic.async_setup_entry", return_value=True, ) as mock_setup_entry: yield mock_setup_entry diff --git a/tests/components/guntamatic_sensor/test_config_flow.py b/tests/components/guntamatic/test_config_flow.py similarity index 88% rename from tests/components/guntamatic_sensor/test_config_flow.py rename to tests/components/guntamatic/test_config_flow.py index c4b72328b73db..c98b21fa2c651 100644 --- a/tests/components/guntamatic_sensor/test_config_flow.py +++ b/tests/components/guntamatic/test_config_flow.py @@ -5,7 +5,7 @@ import requests from homeassistant import config_entries -from homeassistant.components.guntamatic_sensor.const import DOMAIN +from homeassistant.components.guntamatic.const import DOMAIN from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -23,7 +23,7 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: assert result["errors"] == {} with patch( - "homeassistant.components.guntamatic_sensor.config_flow.Heater.get_data", + "homeassistant.components.guntamatic.config_flow.Heater.get_data", return_value=True, ): result = await hass.config_entries.flow.async_configure( @@ -51,7 +51,7 @@ async def test_form_cannot_connect( ) with patch( - "homeassistant.components.guntamatic_sensor.config_flow.Heater.get_data", + "homeassistant.components.guntamatic.config_flow.Heater.get_data", side_effect=requests.exceptions.ConnectionError, ): result = await hass.config_entries.flow.async_configure( @@ -69,7 +69,7 @@ async def test_form_cannot_connect( # we can show the config flow is able to recover from an error. with patch( - "homeassistant.components.guntamatic_sensor.config_flow.Heater.get_data", + "homeassistant.components.guntamatic.config_flow.Heater.get_data", return_value=True, ): result = await hass.config_entries.flow.async_configure( @@ -96,7 +96,7 @@ async def test_form_empty_data( DOMAIN, context={"source": config_entries.SOURCE_USER} ) with patch( - "homeassistant.components.guntamatic_sensor.config_flow.Heater.get_data", + "homeassistant.components.guntamatic.config_flow.Heater.get_data", return_value={}, ): result = await hass.config_entries.flow.async_configure( @@ -115,7 +115,7 @@ async def test_form_unknown_error( DOMAIN, context={"source": config_entries.SOURCE_USER} ) with patch( - "homeassistant.components.guntamatic_sensor.config_flow.Heater.get_data", + "homeassistant.components.guntamatic.config_flow.Heater.get_data", side_effect=Exception("Unknown error"), ): result = await hass.config_entries.flow.async_configure( @@ -137,7 +137,7 @@ async def test_form_already_configured( DOMAIN, context={"source": config_entries.SOURCE_USER} ) with patch( - "homeassistant.components.guntamatic_sensor.config_flow.Heater.get_data", + "homeassistant.components.guntamatic.config_flow.Heater.get_data", return_value=True, ): result = await hass.config_entries.flow.async_configure( @@ -153,7 +153,7 @@ async def test_dhcp_discovery( ) -> None: """Test DHCP discovery.""" with patch( - "homeassistant.components.guntamatic_sensor.config_flow.Heater.get_data", + "homeassistant.components.guntamatic.config_flow.Heater.get_data", return_value=mock_heater, ): result = await hass.config_entries.flow.async_init( diff --git a/tests/components/guntamatic_sensor/test_coordinator.py b/tests/components/guntamatic/test_coordinator.py similarity index 100% rename from tests/components/guntamatic_sensor/test_coordinator.py rename to tests/components/guntamatic/test_coordinator.py diff --git a/tests/components/guntamatic_sensor/test_init.py b/tests/components/guntamatic/test_init.py similarity index 100% rename from tests/components/guntamatic_sensor/test_init.py rename to tests/components/guntamatic/test_init.py diff --git a/tests/components/guntamatic_sensor/test_sensor.py b/tests/components/guntamatic/test_sensor.py similarity index 100% rename from tests/components/guntamatic_sensor/test_sensor.py rename to tests/components/guntamatic/test_sensor.py From 0c1280567f45cdd484561ab4ec470cba6f09e5fe Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Sun, 12 Apr 2026 21:08:38 +0200 Subject: [PATCH 12/43] Fixed pr remarks --- homeassistant/components/guntamatic/__init__.py | 8 ++++---- homeassistant/components/guntamatic/config_flow.py | 2 +- homeassistant/components/guntamatic/const.py | 1 - 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/guntamatic/__init__.py b/homeassistant/components/guntamatic/__init__.py index 3240134d691c9..c2c8acfa631d6 100644 --- a/homeassistant/components/guntamatic/__init__.py +++ b/homeassistant/components/guntamatic/__init__.py @@ -20,16 +20,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up guntamatic from a config entry.""" - host = entry.data[CONF_HOST] - heater = Heater(host) + heater = Heater(entry.data[CONF_HOST]) coordinator = GuntamaticCoordinator(hass, heater, entry) try: - # Fetch initial data await coordinator.async_config_entry_first_refresh() except Exception as err: - raise ConfigEntryNotReady(f"Error while connecting to {host}: {err}") from err + raise ConfigEntryNotReady( + f"Error while connecting to {entry.data[CONF_HOST]}: {err}" + ) from err entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) diff --git a/homeassistant/components/guntamatic/config_flow.py b/homeassistant/components/guntamatic/config_flow.py index c6688631841b9..32ccc02a07dc6 100644 --- a/homeassistant/components/guntamatic/config_flow.py +++ b/homeassistant/components/guntamatic/config_flow.py @@ -59,7 +59,7 @@ async def async_step_user( _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - if not data or not data.get("Serial"): + if not data: errors["base"] = "cannot_connect" else: return self.async_create_entry( diff --git a/homeassistant/components/guntamatic/const.py b/homeassistant/components/guntamatic/const.py index 2aa13125bec92..973b11ad1aad9 100644 --- a/homeassistant/components/guntamatic/const.py +++ b/homeassistant/components/guntamatic/const.py @@ -4,4 +4,3 @@ DOMAIN = "guntamatic_sensor" SCAN_INTERVAL = timedelta(seconds=30) -DIAGNOSTIC_SENSORS = {"Serial", "Version", "Operat. time", "Service Hrs"} From 604a845f4e77f51bd6870045459342d14763dd0d Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Sun, 12 Apr 2026 21:11:38 +0200 Subject: [PATCH 13/43] fixed changed domain in const --- homeassistant/components/guntamatic/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/guntamatic/const.py b/homeassistant/components/guntamatic/const.py index 973b11ad1aad9..022fc4a2dd3d8 100644 --- a/homeassistant/components/guntamatic/const.py +++ b/homeassistant/components/guntamatic/const.py @@ -2,5 +2,5 @@ from datetime import timedelta -DOMAIN = "guntamatic_sensor" +DOMAIN = "guntamatic" SCAN_INTERVAL = timedelta(seconds=30) From 3c4c97eaa5c4c3a1cedfdeb554905921cc5f3ad4 Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Sun, 12 Apr 2026 23:37:39 +0200 Subject: [PATCH 14/43] fix test mocking get_data vs parse_data --- .../components/guntamatic/config_flow.py | 18 +++++++++--------- .../components/guntamatic/coordinator.py | 2 -- .../components/guntamatic/manifest.json | 2 +- homeassistant/components/guntamatic/sensor.py | 1 - .../components/guntamatic/strings.json | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/guntamatic/conftest.py | 1 + .../components/guntamatic/test_config_flow.py | 14 +++++++------- 9 files changed, 21 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/guntamatic/config_flow.py b/homeassistant/components/guntamatic/config_flow.py index 32ccc02a07dc6..0ebda91a691e7 100644 --- a/homeassistant/components/guntamatic/config_flow.py +++ b/homeassistant/components/guntamatic/config_flow.py @@ -5,7 +5,7 @@ import logging from typing import Any -from guntamatic.heater import Heater +from guntamatic.heater import Heater, NoSerialException import requests import voluptuous as vol @@ -28,7 +28,7 @@ class GuntamaticConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for guntamatic.""" VERSION = 1 - _discovered_host = None + _discovered_host: str | None = None async def async_step_dhcp( self, discovery_info: DhcpServiceInfo @@ -52,19 +52,19 @@ async def async_step_user( self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) try: heater = Heater(user_input[CONF_HOST]) - data = await self.hass.async_add_executor_job(heater.parse_data) + await self.hass.async_add_executor_job(heater.parse_data) except requests.exceptions.ConnectionError: errors["base"] = "cannot_connect" + except NoSerialException: + errors["base"] = "bad_data" + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - if not data: - errors["base"] = "cannot_connect" - else: - return self.async_create_entry( - title="Guntamatic Heater", data=user_input - ) + 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 diff --git a/homeassistant/components/guntamatic/coordinator.py b/homeassistant/components/guntamatic/coordinator.py index 9d50f5ade445b..9693c5cc9f5ca 100644 --- a/homeassistant/components/guntamatic/coordinator.py +++ b/homeassistant/components/guntamatic/coordinator.py @@ -48,6 +48,4 @@ async def _async_update_data(self) -> dict[str, list[str]]: raise UpdateFailed(f"Error communicating with heater: {err}") from err if not data: raise UpdateFailed("No data received from heater") - if not data.get("Serial"): - raise UpdateFailed("Could not get serial number from heater") return data diff --git a/homeassistant/components/guntamatic/manifest.json b/homeassistant/components/guntamatic/manifest.json index 289a8e0c4eaac..7eddc7dcfca6a 100644 --- a/homeassistant/components/guntamatic/manifest.json +++ b/homeassistant/components/guntamatic/manifest.json @@ -15,7 +15,7 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["guntamatic==1.1.0"], + "requirements": ["guntamatic==1.2.1"], "ssdp": [], "zeroconf": [] } diff --git a/homeassistant/components/guntamatic/sensor.py b/homeassistant/components/guntamatic/sensor.py index 72ebb1aaedc4a..29f590a3d8f6f 100644 --- a/homeassistant/components/guntamatic/sensor.py +++ b/homeassistant/components/guntamatic/sensor.py @@ -19,7 +19,6 @@ PARALLEL_UPDATES = 0 -# Mapping from unit of measurement to sensor description. GUNTAMATIC_SENSORS: list[SensorEntityDescription] = [ SensorEntityDescription( key="Status", diff --git a/homeassistant/components/guntamatic/strings.json b/homeassistant/components/guntamatic/strings.json index 9688f25f8a90c..18d4f4154df7b 100644 --- a/homeassistant/components/guntamatic/strings.json +++ b/homeassistant/components/guntamatic/strings.json @@ -5,6 +5,7 @@ "not_implemented": "Not Implemented" }, "error": { + "bad_data": "Bad data received from heater (No Serial present in output)", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, diff --git a/requirements_all.txt b/requirements_all.txt index 21aa688a32893..c34f49854dc73 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1155,7 +1155,7 @@ growattServer==1.9.0 gspread==5.5.0 # homeassistant.components.guntamatic -guntamatic==1.1.0 +guntamatic==1.2.1 # homeassistant.components.profiler guppy3==3.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 34e88e5846a5b..ce2ba33617863 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1028,7 +1028,7 @@ growattServer==1.9.0 gspread==5.5.0 # homeassistant.components.guntamatic -guntamatic==1.1.0 +guntamatic==1.2.1 # homeassistant.components.profiler guppy3==3.1.6 diff --git a/tests/components/guntamatic/conftest.py b/tests/components/guntamatic/conftest.py index d1e11538f7adc..4a82ff6c692fb 100644 --- a/tests/components/guntamatic/conftest.py +++ b/tests/components/guntamatic/conftest.py @@ -29,6 +29,7 @@ def mock_heater() -> Generator[MagicMock]: "homeassistant.components.guntamatic.Heater", autospec=True, ) as mock: + mock.return_value.get_data = MagicMock(return_value=MOCK_DATA) mock.return_value.parse_data = MagicMock(return_value=MOCK_DATA) mock.return_value.host = "1.1.1.1" yield mock diff --git a/tests/components/guntamatic/test_config_flow.py b/tests/components/guntamatic/test_config_flow.py index c98b21fa2c651..7ede2f844c1f7 100644 --- a/tests/components/guntamatic/test_config_flow.py +++ b/tests/components/guntamatic/test_config_flow.py @@ -23,7 +23,7 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: assert result["errors"] == {} with patch( - "homeassistant.components.guntamatic.config_flow.Heater.get_data", + "homeassistant.components.guntamatic.config_flow.Heater.parse_data", return_value=True, ): result = await hass.config_entries.flow.async_configure( @@ -51,7 +51,7 @@ async def test_form_cannot_connect( ) with patch( - "homeassistant.components.guntamatic.config_flow.Heater.get_data", + "homeassistant.components.guntamatic.config_flow.Heater.parse_data", side_effect=requests.exceptions.ConnectionError, ): result = await hass.config_entries.flow.async_configure( @@ -69,7 +69,7 @@ async def test_form_cannot_connect( # we can show the config flow is able to recover from an error. with patch( - "homeassistant.components.guntamatic.config_flow.Heater.get_data", + "homeassistant.components.guntamatic.config_flow.Heater.parse_data", return_value=True, ): result = await hass.config_entries.flow.async_configure( @@ -104,7 +104,7 @@ async def test_form_empty_data( {CONF_HOST: "1.1.1.1"}, ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": "bad_data"} async def test_form_unknown_error( @@ -115,7 +115,7 @@ async def test_form_unknown_error( DOMAIN, context={"source": config_entries.SOURCE_USER} ) with patch( - "homeassistant.components.guntamatic.config_flow.Heater.get_data", + "homeassistant.components.guntamatic.config_flow.Heater.parse_data", side_effect=Exception("Unknown error"), ): result = await hass.config_entries.flow.async_configure( @@ -137,7 +137,7 @@ async def test_form_already_configured( DOMAIN, context={"source": config_entries.SOURCE_USER} ) with patch( - "homeassistant.components.guntamatic.config_flow.Heater.get_data", + "homeassistant.components.guntamatic.config_flow.Heater.parse_data", return_value=True, ): result = await hass.config_entries.flow.async_configure( @@ -153,7 +153,7 @@ async def test_dhcp_discovery( ) -> None: """Test DHCP discovery.""" with patch( - "homeassistant.components.guntamatic.config_flow.Heater.get_data", + "homeassistant.components.guntamatic.config_flow.Heater.parse_data", return_value=mock_heater, ): result = await hass.config_entries.flow.async_init( From 3892adaf2184e11855b5bdd5682180ab45dd7b41 Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Mon, 13 Apr 2026 00:37:04 +0200 Subject: [PATCH 15/43] Clean up dhcp discovery flow --- .../components/guntamatic/config_flow.py | 14 +++++----- .../components/guntamatic/coordinator.py | 6 ++--- .../components/guntamatic/test_config_flow.py | 26 +++++++++++++++++++ 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/guntamatic/config_flow.py b/homeassistant/components/guntamatic/config_flow.py index 0ebda91a691e7..8f4212c614520 100644 --- a/homeassistant/components/guntamatic/config_flow.py +++ b/homeassistant/components/guntamatic/config_flow.py @@ -28,17 +28,17 @@ class GuntamaticConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for guntamatic.""" VERSION = 1 - _discovered_host: str | None = None async def async_step_dhcp( self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle DHCP discovery.""" - await self.async_set_unique_id(discovery_info.macaddress) - self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) - - self._discovered_host = discovery_info.ip - return await self.async_step_user() + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, {CONF_HOST: discovery_info.ip} + ), + ) async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -46,8 +46,6 @@ async def async_step_user( """Handle the initial step.""" errors: dict[str, str] = {} - if user_input is None and self._discovered_host is not None: - user_input = {CONF_HOST: self._discovered_host} if user_input is not None: self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) try: diff --git a/homeassistant/components/guntamatic/coordinator.py b/homeassistant/components/guntamatic/coordinator.py index 9693c5cc9f5ca..ac770be4196c1 100644 --- a/homeassistant/components/guntamatic/coordinator.py +++ b/homeassistant/components/guntamatic/coordinator.py @@ -34,9 +34,9 @@ async def _async_update_data(self) -> dict[str, list[str]]: Expected return format: { - "Boiler Temperature": [68.5, "°C"], - "Flue Temperature": [115.2, "°C"], - "Power Output": [12.4, "kW"], + "Boiler Temperature": ["68.5", "°C"], + "Flue Temperature": ["115.2", "°C"], + "Power Output": ["12.4", "kW"], } """ diff --git a/tests/components/guntamatic/test_config_flow.py b/tests/components/guntamatic/test_config_flow.py index 7ede2f844c1f7..31c4f4067b0b6 100644 --- a/tests/components/guntamatic/test_config_flow.py +++ b/tests/components/guntamatic/test_config_flow.py @@ -167,5 +167,31 @@ async def test_dhcp_discovery( ) await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + + +async def test_dhcp_discovery_confirm( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_heater: MagicMock +) -> None: + """Test DHCP discovery confirmation.""" + with patch( + "homeassistant.components.guntamatic.config_flow.Heater.parse_data", + return_value=mock_heater, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="1.1.1.1", + hostname="kessel0001", + macaddress="0024bd123456", + ), + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1"}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == {CONF_HOST: "1.1.1.1"} From f7b606cc591196f62798f99e8b0db322f497b8dc Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Mon, 13 Apr 2026 01:21:13 +0200 Subject: [PATCH 16/43] Clean up dhcp discovery flow --- .../components/guntamatic/config_flow.py | 14 ++- .../components/guntamatic/test_config_flow.py | 116 ++++++++---------- 2 files changed, 66 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/guntamatic/config_flow.py b/homeassistant/components/guntamatic/config_flow.py index 8f4212c614520..8a7f76250568a 100644 --- a/homeassistant/components/guntamatic/config_flow.py +++ b/homeassistant/components/guntamatic/config_flow.py @@ -33,6 +33,10 @@ async def async_step_dhcp( self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle DHCP discovery.""" + # we don't have access to serial yet here without doing a network call to the device + # so dedupe on macc address here, we will overwrite with serial in the next step + await self.async_set_unique_id(discovery_info.macaddress) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) return self.async_show_form( step_id="user", data_schema=self.add_suggested_values_to_schema( @@ -47,10 +51,9 @@ async def async_step_user( errors: dict[str, str] = {} if user_input is not None: - self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) try: heater = Heater(user_input[CONF_HOST]) - await self.hass.async_add_executor_job(heater.parse_data) + data = await self.hass.async_add_executor_job(heater.parse_data) except requests.exceptions.ConnectionError: errors["base"] = "cannot_connect" except NoSerialException: @@ -60,6 +63,13 @@ async def async_step_user( _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: + # set serial as unique id for deduplication, ip isn't a good match + serial = data.get("Serial", [None])[0] + await self.async_set_unique_id(serial) + self._abort_if_unique_id_configured( + updates={CONF_HOST: user_input[CONF_HOST]} + ) + return self.async_create_entry( title="Guntamatic Heater", data=user_input ) diff --git a/tests/components/guntamatic/test_config_flow.py b/tests/components/guntamatic/test_config_flow.py index 31c4f4067b0b6..0baf8ea383130 100644 --- a/tests/components/guntamatic/test_config_flow.py +++ b/tests/components/guntamatic/test_config_flow.py @@ -1,7 +1,8 @@ """Test the guntamatic config flow.""" -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch +from guntamatic.heater import NoSerialException import requests from homeassistant import config_entries @@ -11,6 +12,8 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from .conftest import MOCK_DATA + from tests.common import MockConfigEntry @@ -23,22 +26,18 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: assert result["errors"] == {} with patch( - "homeassistant.components.guntamatic.config_flow.Heater.parse_data", - return_value=True, + "guntamatic.heater.Heater.parse_data", + return_value=MOCK_DATA, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_HOST: "1.1.1.1", - }, + {CONF_HOST: "1.1.1.1"}, ) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Guntamatic Heater" - assert result["data"] == { - CONF_HOST: "1.1.1.1", - } + assert result["data"] == {CONF_HOST: "1.1.1.1"} assert len(mock_setup_entry.mock_calls) == 1 @@ -51,40 +50,30 @@ async def test_form_cannot_connect( ) with patch( - "homeassistant.components.guntamatic.config_flow.Heater.parse_data", + "guntamatic.heater.Heater.parse_data", side_effect=requests.exceptions.ConnectionError, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_HOST: "1.1.1.1", - }, + {CONF_HOST: "1.1.1.1"}, ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "cannot_connect"} - # Make sure the config flow tests finish with either an - # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so - # we can show the config flow is able to recover from an error. - with patch( - "homeassistant.components.guntamatic.config_flow.Heater.parse_data", - return_value=True, + "guntamatic.heater.Heater.parse_data", + return_value=MOCK_DATA, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_HOST: "1.1.1.1", - }, + {CONF_HOST: "1.1.1.1"}, ) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Guntamatic Heater" - assert result["data"] == { - CONF_HOST: "1.1.1.1", - } + assert result["data"] == {CONF_HOST: "1.1.1.1"} assert len(mock_setup_entry.mock_calls) == 1 @@ -92,17 +81,19 @@ async def test_form_empty_data( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test we handle empty data from heater.""" + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) with patch( - "homeassistant.components.guntamatic.config_flow.Heater.get_data", - return_value={}, + "guntamatic.heater.Heater.parse_data", + side_effect=NoSerialException, ): 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": "bad_data"} @@ -115,13 +106,14 @@ async def test_form_unknown_error( DOMAIN, context={"source": config_entries.SOURCE_USER} ) with patch( - "homeassistant.components.guntamatic.config_flow.Heater.parse_data", + "guntamatic.heater.Heater.parse_data", side_effect=Exception("Unknown error"), ): 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": "unknown"} @@ -130,63 +122,63 @@ async def test_form_already_configured( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test we abort if already configured.""" - entry = MockConfigEntry(domain=DOMAIN, data={CONF_HOST: "1.1.1.1"}) + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "1.1.1.1"}, + unique_id=MOCK_DATA["Serial"][0], + ) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) with patch( - "homeassistant.components.guntamatic.config_flow.Heater.parse_data", - return_value=True, + "guntamatic.heater.Heater.parse_data", + return_value=MOCK_DATA, ): 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.""" - with patch( - "homeassistant.components.guntamatic.config_flow.Heater.parse_data", - return_value=mock_heater, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=DhcpServiceInfo( - ip="1.1.1.1", - hostname="kessel0001", - macaddress="0024bd123456", - ), - ) - await hass.async_block_till_done() +async def test_dhcp_discovery(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test DHCP discovery shows confirmation form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="1.1.1.1", + hostname="kessel0001", + macaddress="0024bd123456", + ), + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" async def test_dhcp_discovery_confirm( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_heater: MagicMock + hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: - """Test DHCP discovery confirmation.""" + """Test DHCP discovery confirmation creates entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="1.1.1.1", + hostname="kessel0001", + macaddress="0024bd123456", + ), + ) with patch( - "homeassistant.components.guntamatic.config_flow.Heater.parse_data", - return_value=mock_heater, + "guntamatic.heater.Heater.parse_data", + return_value=MOCK_DATA, ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=DhcpServiceInfo( - ip="1.1.1.1", - hostname="kessel0001", - macaddress="0024bd123456", - ), - ) result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "1.1.1.1"}, From 6c4ddfdf7b738761076a5f35bf0e9452ea50d24e Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Mon, 13 Apr 2026 01:37:02 +0200 Subject: [PATCH 17/43] Addressed remarks --- homeassistant/components/guntamatic/__init__.py | 9 ++------- homeassistant/components/guntamatic/config_flow.py | 4 ++-- homeassistant/components/guntamatic/strings.json | 2 +- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/guntamatic/__init__.py b/homeassistant/components/guntamatic/__init__.py index c2c8acfa631d6..b2b88afcf1d8d 100644 --- a/homeassistant/components/guntamatic/__init__.py +++ b/homeassistant/components/guntamatic/__init__.py @@ -9,7 +9,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from .coordinator import GuntamaticCoordinator @@ -24,12 +23,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = GuntamaticCoordinator(hass, heater, entry) - try: - await coordinator.async_config_entry_first_refresh() - except Exception as err: - raise ConfigEntryNotReady( - f"Error while connecting to {entry.data[CONF_HOST]}: {err}" - ) from err + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) diff --git a/homeassistant/components/guntamatic/config_flow.py b/homeassistant/components/guntamatic/config_flow.py index 8a7f76250568a..6f103d772b9fa 100644 --- a/homeassistant/components/guntamatic/config_flow.py +++ b/homeassistant/components/guntamatic/config_flow.py @@ -34,7 +34,7 @@ async def async_step_dhcp( ) -> ConfigFlowResult: """Handle DHCP discovery.""" # we don't have access to serial yet here without doing a network call to the device - # so dedupe on macc address here, we will overwrite with serial in the next step + # so dedupe on MAC address here, we will overwrite with serial in the next step await self.async_set_unique_id(discovery_info.macaddress) self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) return self.async_show_form( @@ -54,7 +54,7 @@ async def async_step_user( try: heater = Heater(user_input[CONF_HOST]) data = await self.hass.async_add_executor_job(heater.parse_data) - except requests.exceptions.ConnectionError: + except requests.exceptions.RequestException: errors["base"] = "cannot_connect" except NoSerialException: errors["base"] = "bad_data" diff --git a/homeassistant/components/guntamatic/strings.json b/homeassistant/components/guntamatic/strings.json index 18d4f4154df7b..7616c66309961 100644 --- a/homeassistant/components/guntamatic/strings.json +++ b/homeassistant/components/guntamatic/strings.json @@ -5,7 +5,7 @@ "not_implemented": "Not Implemented" }, "error": { - "bad_data": "Bad data received from heater (No Serial present in output)", + "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%]" }, From 29fd73503c682a4f55816ba984f3a98ecd53113d Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Mon, 13 Apr 2026 01:48:25 +0200 Subject: [PATCH 18/43] Update homeassistant/components/guntamatic/strings.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/guntamatic/strings.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/guntamatic/strings.json b/homeassistant/components/guntamatic/strings.json index 7616c66309961..6eac149f14fbd 100644 --- a/homeassistant/components/guntamatic/strings.json +++ b/homeassistant/components/guntamatic/strings.json @@ -1,8 +1,7 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "not_implemented": "Not Implemented" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" }, "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.", From 4a313f8c7193a0d1b6ea01eb4fd2d92fa3eb7c64 Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Mon, 13 Apr 2026 01:55:58 +0200 Subject: [PATCH 19/43] Don't wrap CancelledError --- homeassistant/components/guntamatic/config_flow.py | 3 +++ homeassistant/components/guntamatic/coordinator.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/components/guntamatic/config_flow.py b/homeassistant/components/guntamatic/config_flow.py index 6f103d772b9fa..cfa5a066812ea 100644 --- a/homeassistant/components/guntamatic/config_flow.py +++ b/homeassistant/components/guntamatic/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import logging from typing import Any @@ -58,6 +59,8 @@ async def async_step_user( errors["base"] = "cannot_connect" except NoSerialException: errors["base"] = "bad_data" + except asyncio.CancelledError: + raise except Exception: _LOGGER.exception("Unexpected exception") diff --git a/homeassistant/components/guntamatic/coordinator.py b/homeassistant/components/guntamatic/coordinator.py index ac770be4196c1..41339e218a16a 100644 --- a/homeassistant/components/guntamatic/coordinator.py +++ b/homeassistant/components/guntamatic/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import logging from guntamatic.heater import Heater @@ -44,6 +45,8 @@ async def _async_update_data(self) -> dict[str, list[str]]: data: dict[str, list[str]] = await self.hass.async_add_executor_job( self.heater.parse_data ) + except asyncio.CancelledError: + raise except Exception as err: raise UpdateFailed(f"Error communicating with heater: {err}") from err if not data: From 943e7c52dc6f11352bab1c7263fee7e8c40c193a Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:48:32 +0200 Subject: [PATCH 20/43] Created composite and snapshot tests --- .../components/guntamatic/config_flow.py | 5 +- .../components/guntamatic/coordinator.py | 26 +- .../components/guntamatic/manifest.json | 4 +- homeassistant/components/guntamatic/sensor.py | 9 +- tests/components/guntamatic/__init__.py | 16 +- .../guntamatic/snapshots/test_sensor.ambr | 391 ++++++++++++++++++ .../components/guntamatic/test_config_flow.py | 68 +-- .../components/guntamatic/test_coordinator.py | 35 +- tests/components/guntamatic/test_init.py | 38 +- tests/components/guntamatic/test_sensor.py | 53 ++- 10 files changed, 495 insertions(+), 150 deletions(-) create mode 100644 tests/components/guntamatic/snapshots/test_sensor.ambr diff --git a/homeassistant/components/guntamatic/config_flow.py b/homeassistant/components/guntamatic/config_flow.py index cfa5a066812ea..5603170ee444c 100644 --- a/homeassistant/components/guntamatic/config_flow.py +++ b/homeassistant/components/guntamatic/config_flow.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio import logging from typing import Any @@ -59,15 +58,13 @@ async def async_step_user( errors["base"] = "cannot_connect" except NoSerialException: errors["base"] = "bad_data" - except asyncio.CancelledError: - raise except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: # set serial as unique id for deduplication, ip isn't a good match - serial = data.get("Serial", [None])[0] + serial = data["Serial"][0] await self.async_set_unique_id(serial) self._abort_if_unique_id_configured( updates={CONF_HOST: user_input[CONF_HOST]} diff --git a/homeassistant/components/guntamatic/coordinator.py b/homeassistant/components/guntamatic/coordinator.py index 41339e218a16a..33f5308646bef 100644 --- a/homeassistant/components/guntamatic/coordinator.py +++ b/homeassistant/components/guntamatic/coordinator.py @@ -2,13 +2,14 @@ from __future__ import annotations -import asyncio import logging -from guntamatic.heater import Heater +from guntamatic.heater import Heater, NoSerialException +import requests from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, SCAN_INTERVAL @@ -31,24 +32,13 @@ def __init__(self, hass: HomeAssistant, heater: Heater, entry: ConfigEntry) -> N self.heater = heater async def _async_update_data(self) -> dict[str, list[str]]: - """Fetch data from heater. - - Expected return format: - { - "Boiler Temperature": ["68.5", "°C"], - "Flue Temperature": ["115.2", "°C"], - "Power Output": ["12.4", "kW"], - } - - """ + """Fetch data from heater.""" try: data: dict[str, list[str]] = await self.hass.async_add_executor_job( self.heater.parse_data ) - except asyncio.CancelledError: - raise - except Exception as err: - raise UpdateFailed(f"Error communicating with heater: {err}") from err - if not data: - raise UpdateFailed("No data received from heater") + except requests.exceptions.ConnectionError as err: + raise UpdateFailed(f"Cannot connect to heater: {err}") from err + except NoSerialException as err: + raise ConfigEntryError(f"Unexpected data from heater: {err}") from err return data diff --git a/homeassistant/components/guntamatic/manifest.json b/homeassistant/components/guntamatic/manifest.json index 7eddc7dcfca6a..cbbc2607bc60f 100644 --- a/homeassistant/components/guntamatic/manifest.json +++ b/homeassistant/components/guntamatic/manifest.json @@ -15,7 +15,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["guntamatic==1.2.1"], - "ssdp": [], - "zeroconf": [] + "requirements": ["guntamatic==1.2.1"] } diff --git a/homeassistant/components/guntamatic/sensor.py b/homeassistant/components/guntamatic/sensor.py index 29f590a3d8f6f..559e03d8fba63 100644 --- a/homeassistant/components/guntamatic/sensor.py +++ b/homeassistant/components/guntamatic/sensor.py @@ -147,14 +147,7 @@ def __init__( @property def native_value(self) -> StateType: """Return the current value of the sensor.""" - value = self.coordinator.data[self._name][0] - - if self.entity_description.state_class == SensorStateClass.MEASUREMENT: - try: - return float(value) - except TypeError, ValueError: - return value - return value + return self.coordinator.data[self._name][0] @property def available(self) -> bool: diff --git a/tests/components/guntamatic/__init__.py b/tests/components/guntamatic/__init__.py index b6d6c20a2367a..e91bc419c1828 100644 --- a/tests/components/guntamatic/__init__.py +++ b/tests/components/guntamatic/__init__.py @@ -1 +1,15 @@ -"""Tests for the guntamatic integration.""" +"""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() diff --git a/tests/components/guntamatic/snapshots/test_sensor.ambr b/tests/components/guntamatic/snapshots/test_sensor.ambr new file mode 100644 index 0000000000000..a7a50ca724ba0 --- /dev/null +++ b/tests/components/guntamatic/snapshots/test_sensor.ambr @@ -0,0 +1,391 @@ +# serializer version: 1 +# name: test_all_entities[sensor.guntamatic_heater_boiler_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.guntamatic_heater_boiler_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Boiler Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Boiler Temperature', + 'platform': 'guntamatic', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '959103_Boiler_Temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.guntamatic_heater_boiler_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Guntamatic Heater Boiler Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.guntamatic_heater_boiler_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.09', + }) +# --- +# name: test_all_entities[sensor.guntamatic_heater_buffer_load-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.guntamatic_heater_buffer_load', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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': None, + 'unique_id': '959103_Buffer_Load', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.guntamatic_heater_buffer_load-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Guntamatic Heater Buffer Load', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.guntamatic_heater_buffer_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22', + }) +# --- +# name: test_all_entities[sensor.guntamatic_heater_outdoor_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.guntamatic_heater_outdoor_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Outdoor Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor Temperature', + 'platform': 'guntamatic', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '959103_Outdoor_Temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.guntamatic_heater_outdoor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Guntamatic Heater Outdoor Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.guntamatic_heater_outdoor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.95', + }) +# --- +# name: test_all_entities[sensor.guntamatic_heater_program-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'OFF', + 'TIMER', + 'DHW', + 'HEAT', + 'HIBERNAT', + 'HIBERNATE TO', + 'DHW BOOST', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.guntamatic_heater_program', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Program', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program', + 'platform': 'guntamatic', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '959103_Program', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.guntamatic_heater_program-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Guntamatic Heater Program', + 'options': list([ + 'OFF', + 'TIMER', + 'DHW', + 'HEAT', + 'HIBERNAT', + 'HIBERNATE TO', + 'DHW BOOST', + ]), + }), + 'context': , + 'entity_id': 'sensor.guntamatic_heater_program', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'HEAT', + }) +# --- +# name: test_all_entities[sensor.guntamatic_heater_serial-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.guntamatic_heater_serial', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Serial', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Serial', + 'platform': 'guntamatic', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '959103_Serial', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.guntamatic_heater_serial-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Guntamatic Heater Serial', + }), + 'context': , + 'entity_id': 'sensor.guntamatic_heater_serial', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '959103', + }) +# --- +# name: test_all_entities[sensor.guntamatic_heater_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.guntamatic_heater_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Status', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'guntamatic', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '959103_Status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.guntamatic_heater_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Guntamatic Heater Status', + }), + 'context': , + 'entity_id': 'sensor.guntamatic_heater_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Service Ign.', + }) +# --- +# name: test_all_entities[sensor.guntamatic_heater_version-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.guntamatic_heater_version', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Version', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Version', + 'platform': 'guntamatic', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '959103_Version', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.guntamatic_heater_version-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Guntamatic Heater Version', + }), + 'context': , + 'entity_id': 'sensor.guntamatic_heater_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32a', + }) +# --- diff --git a/tests/components/guntamatic/test_config_flow.py b/tests/components/guntamatic/test_config_flow.py index 0baf8ea383130..b75699f0fcc29 100644 --- a/tests/components/guntamatic/test_config_flow.py +++ b/tests/components/guntamatic/test_config_flow.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, patch from guntamatic.heater import NoSerialException +import pytest import requests from homeassistant import config_entries @@ -41,17 +42,27 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_cannot_connect( - hass: HomeAssistant, mock_setup_entry: AsyncMock +@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, ) -> None: - """Test we handle cannot connect error.""" + """Test we handle errors correctly.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( "guntamatic.heater.Heater.parse_data", - side_effect=requests.exceptions.ConnectionError, + side_effect=side_effect, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -59,8 +70,9 @@ async def test_form_cannot_connect( ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": expected_error} + # Recover from error with patch( "guntamatic.heater.Heater.parse_data", return_value=MOCK_DATA, @@ -72,50 +84,6 @@ async def test_form_cannot_connect( await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Guntamatic Heater" - assert result["data"] == {CONF_HOST: "1.1.1.1"} - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_form_empty_data( - hass: HomeAssistant, mock_setup_entry: AsyncMock -) -> None: - """Test we handle empty data from heater.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - with patch( - "guntamatic.heater.Heater.parse_data", - side_effect=NoSerialException, - ): - 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": "bad_data"} - - -async def test_form_unknown_error( - hass: HomeAssistant, mock_setup_entry: AsyncMock -) -> None: - """Test we handle unknown errors.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - with patch( - "guntamatic.heater.Heater.parse_data", - side_effect=Exception("Unknown error"), - ): - 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": "unknown"} async def test_form_already_configured( diff --git a/tests/components/guntamatic/test_coordinator.py b/tests/components/guntamatic/test_coordinator.py index 0a6de9d202004..a744a55e8cddd 100644 --- a/tests/components/guntamatic/test_coordinator.py +++ b/tests/components/guntamatic/test_coordinator.py @@ -2,6 +2,10 @@ from unittest.mock import MagicMock +from guntamatic.heater import NoSerialException +import pytest +import requests + from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -9,36 +13,27 @@ from tests.common import MockConfigEntry +@pytest.mark.parametrize( + "side_effect", + [ + requests.exceptions.ConnectionError("Connection lost"), + NoSerialException, + Exception("Unknown error"), + ], +) async def test_coordinator_update_failed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_heater: MagicMock, + side_effect: Exception, ) -> None: - """Test coordinator handles update failure.""" + """Test coordinator handles update failures.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.LOADED - mock_heater.return_value.parse_data.side_effect = Exception("Connection lost") - await mock_config_entry.runtime_data.async_refresh() - await hass.async_block_till_done() - - state = hass.states.get("sensor.guntamatic_heater_boiler_temperature") - assert state.state == STATE_UNAVAILABLE - - -async def test_coordinator_empty_data( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_heater: MagicMock, -) -> None: - """Test coordinator handles empty data.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - mock_heater.return_value.parse_data.return_value = {} + mock_heater.return_value.parse_data.side_effect = side_effect await mock_config_entry.runtime_data.async_refresh() await hass.async_block_till_done() diff --git a/tests/components/guntamatic/test_init.py b/tests/components/guntamatic/test_init.py index 60ddede030d53..f564ab7cb58e6 100644 --- a/tests/components/guntamatic/test_init.py +++ b/tests/components/guntamatic/test_init.py @@ -2,6 +2,10 @@ from unittest.mock import MagicMock +from guntamatic.heater import NoSerialException +import pytest +import requests + from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -20,17 +24,29 @@ async def test_setup_entry( assert mock_config_entry.state is ConfigEntryState.LOADED -async def test_setup_entry_cannot_connect( +@pytest.mark.parametrize( + ("side_effect", "expected_state"), + [ + ( + requests.exceptions.ConnectionError("Cannot connect"), + ConfigEntryState.SETUP_RETRY, + ), + (NoSerialException, ConfigEntryState.SETUP_ERROR), + ], +) +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 when heater is unreachable.""" - mock_heater.return_value.parse_data.side_effect = Exception("Cannot connect") + """Test setup fails correctly for different error types.""" + mock_heater.return_value.parse_data.side_effect = side_effect mock_config_entry.add_to_hass(hass) 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 + assert mock_config_entry.state is expected_state async def test_unload_entry( @@ -42,19 +58,5 @@ async def test_unload_entry( mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert await hass.config_entries.async_unload(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.NOT_LOADED - - -async def test_setup_entry_empty_data( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_heater: MagicMock, -) -> None: - """Test setup fails when heater returns no data.""" - mock_heater.return_value.parse_data.return_value = {} - mock_config_entry.add_to_hass(hass) - 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 diff --git a/tests/components/guntamatic/test_sensor.py b/tests/components/guntamatic/test_sensor.py index b8c1a60e665f9..b4fea8f9c0201 100644 --- a/tests/components/guntamatic/test_sensor.py +++ b/tests/components/guntamatic/test_sensor.py @@ -1,38 +1,35 @@ -"""Test the Guntamatic sensors.""" +"""Tests for the Guntamatic sensor platform.""" -from unittest.mock import MagicMock +from unittest.mock import patch +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry +from . import setup_integration +from tests.common import MockConfigEntry, snapshot_platform -async def test_sensors_created( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_heater: MagicMock, -) -> None: - """Test that sensors are created for each data point.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - state = hass.states.get("sensor.guntamatic_heater_boiler_temperature") - assert state is not None - assert state.state == "14.09" - assert state.attributes["unit_of_measurement"] == "°C" - - -async def test_sensor_native_value( +@pytest.mark.usefixtures("mock_heater") +async def test_all_entities( hass: HomeAssistant, + snapshot: SnapshotAssertion, mock_config_entry: MockConfigEntry, - mock_heater: MagicMock, + entity_registry: er.EntityRegistry, ) -> None: - """Test sensor returns correct native value.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("sensor.guntamatic_heater_outdoor_temperature") - assert state is not None - assert state.state == "15.95" + """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, + ) From f8c013018c54af897d88933536091edcd4d49d68 Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:38:32 +0200 Subject: [PATCH 21/43] Bump dependency --- homeassistant/components/guntamatic/manifest.json | 2 +- homeassistant/components/guntamatic/sensor.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/guntamatic/manifest.json b/homeassistant/components/guntamatic/manifest.json index cbbc2607bc60f..3d764e60ed7bd 100644 --- a/homeassistant/components/guntamatic/manifest.json +++ b/homeassistant/components/guntamatic/manifest.json @@ -15,5 +15,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["guntamatic==1.2.1"] + "requirements": ["guntamatic==1.3.0"] } diff --git a/homeassistant/components/guntamatic/sensor.py b/homeassistant/components/guntamatic/sensor.py index 559e03d8fba63..cf26ae27285c7 100644 --- a/homeassistant/components/guntamatic/sensor.py +++ b/homeassistant/components/guntamatic/sensor.py @@ -141,7 +141,7 @@ def __init__( name="Guntamatic Heater", manufacturer="Guntamatic", serial_number=serial, - sw_version=coordinator.data.get("Version", [""])[0] or None, + sw_version=coordinator.data["Version"][0], ) @property diff --git a/requirements_all.txt b/requirements_all.txt index 0bfcbff1ba9fd..e6a9a7d2649af 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1161,7 +1161,7 @@ growattServer==1.9.0 gspread==5.5.0 # homeassistant.components.guntamatic -guntamatic==1.2.1 +guntamatic==1.3.0 # homeassistant.components.profiler guppy3==3.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8935830a9aa5a..34f2bed497ff6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1034,7 +1034,7 @@ growattServer==1.9.0 gspread==5.5.0 # homeassistant.components.guntamatic -guntamatic==1.2.1 +guntamatic==1.3.0 # homeassistant.components.profiler guppy3==3.1.6 From 5b724e73c920393bb76cdf81b963585b26c79e89 Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:49:36 +0200 Subject: [PATCH 22/43] Don't raise ConfigEntryError on regular updates --- homeassistant/components/guntamatic/__init__.py | 10 +++++++++- homeassistant/components/guntamatic/coordinator.py | 3 +-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/guntamatic/__init__.py b/homeassistant/components/guntamatic/__init__.py index b2b88afcf1d8d..56d2821f3354b 100644 --- a/homeassistant/components/guntamatic/__init__.py +++ b/homeassistant/components/guntamatic/__init__.py @@ -4,11 +4,12 @@ import logging -from guntamatic.heater import Heater +from guntamatic.heater import Heater, NoSerialException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from .coordinator import GuntamaticCoordinator @@ -21,6 +22,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: heater = Heater(entry.data[CONF_HOST]) + try: + await hass.async_add_executor_job(heater.parse_data) + except NoSerialException as err: + raise ConfigEntryError(f"Unexpected data from heater: {err}") from err + except Exception as err: + raise ConfigEntryNotReady(f"Cannot connect to heater: {err}") from err + coordinator = GuntamaticCoordinator(hass, heater, entry) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/guntamatic/coordinator.py b/homeassistant/components/guntamatic/coordinator.py index 33f5308646bef..d0c4df3e511d8 100644 --- a/homeassistant/components/guntamatic/coordinator.py +++ b/homeassistant/components/guntamatic/coordinator.py @@ -9,7 +9,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, SCAN_INTERVAL @@ -40,5 +39,5 @@ async def _async_update_data(self) -> dict[str, list[str]]: except requests.exceptions.ConnectionError as err: raise UpdateFailed(f"Cannot connect to heater: {err}") from err except NoSerialException as err: - raise ConfigEntryError(f"Unexpected data from heater: {err}") from err + raise UpdateFailed(f"Unexpected data from heater: {err}") from err return data From 7c7f3bcaefc0028e1b96b7f0814d512133781482 Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:52:15 +0200 Subject: [PATCH 23/43] Add GuntamaticConfigEntry type --- homeassistant/components/guntamatic/__init__.py | 4 +++- homeassistant/components/guntamatic/sensor.py | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/guntamatic/__init__.py b/homeassistant/components/guntamatic/__init__.py index 56d2821f3354b..dfa9972e654d9 100644 --- a/homeassistant/components/guntamatic/__init__.py +++ b/homeassistant/components/guntamatic/__init__.py @@ -16,8 +16,10 @@ _LOGGER = logging.getLogger(__name__) _PLATFORMS: list[Platform] = [Platform.SENSOR] +type GuntamaticConfigEntry = ConfigEntry[GuntamaticCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: GuntamaticConfigEntry) -> bool: """Set up guntamatic from a config entry.""" heater = Heater(entry.data[CONF_HOST]) diff --git a/homeassistant/components/guntamatic/sensor.py b/homeassistant/components/guntamatic/sensor.py index cf26ae27285c7..3d31e6d3d4519 100644 --- a/homeassistant/components/guntamatic/sensor.py +++ b/homeassistant/components/guntamatic/sensor.py @@ -7,13 +7,13 @@ SensorStateClass, StateType, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, 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 . import GuntamaticConfigEntry from .const import DOMAIN from .coordinator import GuntamaticCoordinator @@ -97,7 +97,7 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GuntamaticConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Guntamatic sensors from config entry.""" From 9950e6e6215564ef3d9a3596c018431ce63ca714 Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:03:26 +0200 Subject: [PATCH 24/43] Update homeassistant/components/guntamatic/__init__.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/guntamatic/__init__.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/guntamatic/__init__.py b/homeassistant/components/guntamatic/__init__.py index dfa9972e654d9..a99c2e21800f0 100644 --- a/homeassistant/components/guntamatic/__init__.py +++ b/homeassistant/components/guntamatic/__init__.py @@ -23,18 +23,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: GuntamaticConfigEntry) - """Set up guntamatic from a config entry.""" heater = Heater(entry.data[CONF_HOST]) + coordinator = GuntamaticCoordinator(hass, heater, entry) try: - await hass.async_add_executor_job(heater.parse_data) + await coordinator.async_config_entry_first_refresh() except NoSerialException as err: raise ConfigEntryError(f"Unexpected data from heater: {err}") from err except Exception as err: raise ConfigEntryNotReady(f"Cannot connect to heater: {err}") from err - - coordinator = GuntamaticCoordinator(hass, heater, entry) - - await coordinator.async_config_entry_first_refresh() - entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) From c82bf5e576b05f83c0fbfdaaaf1f06f71a6f669c Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:19:10 +0200 Subject: [PATCH 25/43] Catch NoSerialException in init without duplicate network calls, test recovery --- .../components/guntamatic/__init__.py | 18 +++++++++++------- .../components/guntamatic/coordinator.py | 6 +++--- homeassistant/components/guntamatic/sensor.py | 3 +-- .../components/guntamatic/test_coordinator.py | 9 +++++++++ tests/components/guntamatic/test_init.py | 1 + 5 files changed, 25 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/guntamatic/__init__.py b/homeassistant/components/guntamatic/__init__.py index a99c2e21800f0..7d4c0757b8e38 100644 --- a/homeassistant/components/guntamatic/__init__.py +++ b/homeassistant/components/guntamatic/__init__.py @@ -5,32 +5,36 @@ import logging from guntamatic.heater import Heater, NoSerialException +import requests from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady -from .coordinator import GuntamaticCoordinator +from .coordinator import GuntamaticConfigEntry, GuntamaticCoordinator _LOGGER = logging.getLogger(__name__) _PLATFORMS: list[Platform] = [Platform.SENSOR] -type GuntamaticConfigEntry = ConfigEntry[GuntamaticCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: GuntamaticConfigEntry) -> bool: """Set up guntamatic from a config entry.""" heater = Heater(entry.data[CONF_HOST]) - coordinator = GuntamaticCoordinator(hass, heater, entry) try: - await coordinator.async_config_entry_first_refresh() + initial_data = await hass.async_add_executor_job(heater.parse_data) except NoSerialException as err: - raise ConfigEntryError(f"Unexpected data from heater: {err}") from err - except Exception as err: + raise ConfigEntryError(str(err)) from err + except requests.exceptions.ConnectionError as err: raise ConfigEntryNotReady(f"Cannot connect to heater: {err}") from err + except Exception as err: + raise ConfigEntryError(f"Unexpected error: {err}") from err + + coordinator = GuntamaticCoordinator(hass, heater, entry) + # Set initial data without doing extra network call + coordinator.async_set_updated_data(initial_data) entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) diff --git a/homeassistant/components/guntamatic/coordinator.py b/homeassistant/components/guntamatic/coordinator.py index d0c4df3e511d8..aa18376a6d9ff 100644 --- a/homeassistant/components/guntamatic/coordinator.py +++ b/homeassistant/components/guntamatic/coordinator.py @@ -4,7 +4,7 @@ import logging -from guntamatic.heater import Heater, NoSerialException +from guntamatic.heater import Heater import requests from homeassistant.config_entries import ConfigEntry @@ -15,6 +15,8 @@ _LOGGER = logging.getLogger(__name__) +type GuntamaticConfigEntry = ConfigEntry[GuntamaticCoordinator] + class GuntamaticCoordinator(DataUpdateCoordinator[dict[str, list[str]]]): """Guntamatic data coordinator.""" @@ -38,6 +40,4 @@ async def _async_update_data(self) -> dict[str, list[str]]: ) except requests.exceptions.ConnectionError as err: raise UpdateFailed(f"Cannot connect to heater: {err}") from err - except NoSerialException as err: - raise UpdateFailed(f"Unexpected data from heater: {err}") from err return data diff --git a/homeassistant/components/guntamatic/sensor.py b/homeassistant/components/guntamatic/sensor.py index 3d31e6d3d4519..3600017025a6d 100644 --- a/homeassistant/components/guntamatic/sensor.py +++ b/homeassistant/components/guntamatic/sensor.py @@ -13,9 +13,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import GuntamaticConfigEntry from .const import DOMAIN -from .coordinator import GuntamaticCoordinator +from .coordinator import GuntamaticConfigEntry, GuntamaticCoordinator PARALLEL_UPDATES = 0 diff --git a/tests/components/guntamatic/test_coordinator.py b/tests/components/guntamatic/test_coordinator.py index a744a55e8cddd..934ef7a234000 100644 --- a/tests/components/guntamatic/test_coordinator.py +++ b/tests/components/guntamatic/test_coordinator.py @@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +from tests.components.guntamatic.conftest import MOCK_DATA @pytest.mark.parametrize( @@ -39,3 +40,11 @@ async def test_coordinator_update_failed( state = hass.states.get("sensor.guntamatic_heater_boiler_temperature") assert state.state == STATE_UNAVAILABLE + + # Recovery + mock_heater.return_value.parse_data.side_effect = None + mock_heater.return_value.parse_data.return_value = MOCK_DATA + await mock_config_entry.runtime_data.async_refresh() + await hass.async_block_till_done() + state = hass.states.get("sensor.guntamatic_heater_boiler_temperature") + assert state.state != STATE_UNAVAILABLE diff --git a/tests/components/guntamatic/test_init.py b/tests/components/guntamatic/test_init.py index f564ab7cb58e6..e2561007fa32d 100644 --- a/tests/components/guntamatic/test_init.py +++ b/tests/components/guntamatic/test_init.py @@ -32,6 +32,7 @@ async def test_setup_entry( ConfigEntryState.SETUP_RETRY, ), (NoSerialException, ConfigEntryState.SETUP_ERROR), + (Exception("Unexpected error"), ConfigEntryState.SETUP_ERROR), ], ) async def test_setup_entry_fails( From ca98c9d3c0c2c3d2f378c70f87e464c8d433b46a Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:32:55 +0200 Subject: [PATCH 26/43] Fix test --- tests/components/guntamatic/test_coordinator.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/components/guntamatic/test_coordinator.py b/tests/components/guntamatic/test_coordinator.py index 934ef7a234000..9d6d0237f9aea 100644 --- a/tests/components/guntamatic/test_coordinator.py +++ b/tests/components/guntamatic/test_coordinator.py @@ -10,8 +10,9 @@ from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +from .conftest import MOCK_DATA + from tests.common import MockConfigEntry -from tests.components.guntamatic.conftest import MOCK_DATA @pytest.mark.parametrize( From 7dfde24290b4f9087bb322f5ecedd9ae284a2ab1 Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:02:02 +0200 Subject: [PATCH 27/43] Added async setup in coordinator --- homeassistant/components/guntamatic/__init__.py | 16 ++-------------- .../components/guntamatic/coordinator.py | 12 +++++++++++- tests/components/guntamatic/test_init.py | 1 - 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/guntamatic/__init__.py b/homeassistant/components/guntamatic/__init__.py index 7d4c0757b8e38..f3ef3f9eccb64 100644 --- a/homeassistant/components/guntamatic/__init__.py +++ b/homeassistant/components/guntamatic/__init__.py @@ -4,13 +4,11 @@ import logging -from guntamatic.heater import Heater, NoSerialException -import requests +from guntamatic.heater import Heater from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from .coordinator import GuntamaticConfigEntry, GuntamaticCoordinator @@ -23,18 +21,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: GuntamaticConfigEntry) - heater = Heater(entry.data[CONF_HOST]) - try: - initial_data = await hass.async_add_executor_job(heater.parse_data) - except NoSerialException as err: - raise ConfigEntryError(str(err)) from err - except requests.exceptions.ConnectionError as err: - raise ConfigEntryNotReady(f"Cannot connect to heater: {err}") from err - except Exception as err: - raise ConfigEntryError(f"Unexpected error: {err}") from err - coordinator = GuntamaticCoordinator(hass, heater, entry) - # Set initial data without doing extra network call - coordinator.async_set_updated_data(initial_data) + await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) diff --git a/homeassistant/components/guntamatic/coordinator.py b/homeassistant/components/guntamatic/coordinator.py index aa18376a6d9ff..189586ba7ad3d 100644 --- a/homeassistant/components/guntamatic/coordinator.py +++ b/homeassistant/components/guntamatic/coordinator.py @@ -4,11 +4,12 @@ import logging -from guntamatic.heater import Heater +from guntamatic.heater import Heater, NoSerialException import requests from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, SCAN_INTERVAL @@ -32,6 +33,15 @@ def __init__(self, hass: HomeAssistant, heater: Heater, entry: ConfigEntry) -> N ) self.heater = heater + async def _async_setup(self) -> None: + """Do initialization logic.""" + try: + await self.hass.async_add_executor_job(self.heater.parse_data) + except NoSerialException as err: + raise ConfigEntryError(f"Unexpected data from heater: {err}") from err + except requests.exceptions.ConnectionError as err: + raise ConfigEntryNotReady(f"Cannot connect to heater: {err}") from err + async def _async_update_data(self) -> dict[str, list[str]]: """Fetch data from heater.""" try: diff --git a/tests/components/guntamatic/test_init.py b/tests/components/guntamatic/test_init.py index e2561007fa32d..f564ab7cb58e6 100644 --- a/tests/components/guntamatic/test_init.py +++ b/tests/components/guntamatic/test_init.py @@ -32,7 +32,6 @@ async def test_setup_entry( ConfigEntryState.SETUP_RETRY, ), (NoSerialException, ConfigEntryState.SETUP_ERROR), - (Exception("Unexpected error"), ConfigEntryState.SETUP_ERROR), ], ) async def test_setup_entry_fails( From ec42fec9b1a61a9d76965f6e0f63f0248b35dffd Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Fri, 17 Apr 2026 00:38:41 +0200 Subject: [PATCH 28/43] Code cleanup --- .../components/guntamatic/__init__.py | 3 +- .../components/guntamatic/config_flow.py | 4 +- .../components/guntamatic/coordinator.py | 4 +- homeassistant/components/guntamatic/sensor.py | 7 -- .../guntamatic/snapshots/test_sensor.ambr | 100 ------------------ 5 files changed, 5 insertions(+), 113 deletions(-) diff --git a/homeassistant/components/guntamatic/__init__.py b/homeassistant/components/guntamatic/__init__.py index f3ef3f9eccb64..e3db98c3ed41a 100644 --- a/homeassistant/components/guntamatic/__init__.py +++ b/homeassistant/components/guntamatic/__init__.py @@ -6,7 +6,6 @@ from guntamatic.heater import Heater -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant @@ -29,6 +28,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: GuntamaticConfigEntry) - return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: GuntamaticConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/guntamatic/config_flow.py b/homeassistant/components/guntamatic/config_flow.py index 5603170ee444c..88777dad4e4a7 100644 --- a/homeassistant/components/guntamatic/config_flow.py +++ b/homeassistant/components/guntamatic/config_flow.py @@ -66,9 +66,7 @@ async def async_step_user( # set serial as unique id for deduplication, ip isn't a good match serial = data["Serial"][0] await self.async_set_unique_id(serial) - self._abort_if_unique_id_configured( - updates={CONF_HOST: user_input[CONF_HOST]} - ) + self._abort_if_unique_id_configured() return self.async_create_entry( title="Guntamatic Heater", data=user_input diff --git a/homeassistant/components/guntamatic/coordinator.py b/homeassistant/components/guntamatic/coordinator.py index 189586ba7ad3d..7c3de3fd2da61 100644 --- a/homeassistant/components/guntamatic/coordinator.py +++ b/homeassistant/components/guntamatic/coordinator.py @@ -22,7 +22,9 @@ class GuntamaticCoordinator(DataUpdateCoordinator[dict[str, list[str]]]): """Guntamatic data coordinator.""" - def __init__(self, hass: HomeAssistant, heater: Heater, entry: ConfigEntry) -> None: + def __init__( + self, hass: HomeAssistant, heater: Heater, entry: GuntamaticConfigEntry + ) -> None: """Initialize coordinator.""" super().__init__( hass, diff --git a/homeassistant/components/guntamatic/sensor.py b/homeassistant/components/guntamatic/sensor.py index 3600017025a6d..f06ede2a86beb 100644 --- a/homeassistant/components/guntamatic/sensor.py +++ b/homeassistant/components/guntamatic/sensor.py @@ -36,8 +36,6 @@ "DHW BOOST", ], ), - SensorEntityDescription(key="Serial", entity_category=EntityCategory.DIAGNOSTIC), - SensorEntityDescription(key="Version", entity_category=EntityCategory.DIAGNOSTIC), SensorEntityDescription( key="Boiler Temperature", device_class=SensorDeviceClass.TEMPERATURE, @@ -147,8 +145,3 @@ def __init__( def native_value(self) -> StateType: """Return the current value of the sensor.""" return self.coordinator.data[self._name][0] - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return super().available and self._name in self.coordinator.data diff --git a/tests/components/guntamatic/snapshots/test_sensor.ambr b/tests/components/guntamatic/snapshots/test_sensor.ambr index a7a50ca724ba0..8ff6b25b3599e 100644 --- a/tests/components/guntamatic/snapshots/test_sensor.ambr +++ b/tests/components/guntamatic/snapshots/test_sensor.ambr @@ -239,56 +239,6 @@ 'state': 'HEAT', }) # --- -# name: test_all_entities[sensor.guntamatic_heater_serial-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.guntamatic_heater_serial', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Serial', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Serial', - 'platform': 'guntamatic', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '959103_Serial', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.guntamatic_heater_serial-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Guntamatic Heater Serial', - }), - 'context': , - 'entity_id': 'sensor.guntamatic_heater_serial', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '959103', - }) -# --- # name: test_all_entities[sensor.guntamatic_heater_status-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -339,53 +289,3 @@ 'state': 'Service Ign.', }) # --- -# name: test_all_entities[sensor.guntamatic_heater_version-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.guntamatic_heater_version', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Version', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Version', - 'platform': 'guntamatic', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '959103_Version', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.guntamatic_heater_version-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Guntamatic Heater Version', - }), - 'context': , - 'entity_id': 'sensor.guntamatic_heater_version', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '32a', - }) -# --- From eecd590c0cf7bb3b351601119d57911757fc14d9 Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Sun, 19 Apr 2026 22:07:08 +0200 Subject: [PATCH 29/43] Translations added --- .../components/guntamatic/manifest.json | 2 +- homeassistant/components/guntamatic/sensor.py | 58 +++++++++----- .../components/guntamatic/strings.json | 49 ++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/guntamatic/conftest.py | 12 ++- .../guntamatic/snapshots/test_sensor.ambr | 80 +++++++++---------- .../components/guntamatic/test_coordinator.py | 4 +- 8 files changed, 143 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/guntamatic/manifest.json b/homeassistant/components/guntamatic/manifest.json index 3d764e60ed7bd..0f7ddb6477d81 100644 --- a/homeassistant/components/guntamatic/manifest.json +++ b/homeassistant/components/guntamatic/manifest.json @@ -15,5 +15,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["guntamatic==1.3.0"] + "requirements": ["guntamatic==1.5.0"] } diff --git a/homeassistant/components/guntamatic/sensor.py b/homeassistant/components/guntamatic/sensor.py index f06ede2a86beb..654b9703d57ee 100644 --- a/homeassistant/components/guntamatic/sensor.py +++ b/homeassistant/components/guntamatic/sensor.py @@ -20,72 +20,90 @@ GUNTAMATIC_SENSORS: list[SensorEntityDescription] = [ SensorEntityDescription( - key="Status", + key="status", + translation_key="status", entity_category=EntityCategory.DIAGNOSTIC, ), SensorEntityDescription( - key="Program", + key="program", + translation_key="program", device_class=SensorDeviceClass.ENUM, options=[ - "OFF", - "TIMER", - "DHW", - "HEAT", - "HIBERNAT", - "HIBERNATE TO", - "DHW BOOST", + "off", + "timer", + "dhw", + "heat", + "hibernate", + "hibernate_to", + "dhw_boost", ], ), SensorEntityDescription( - key="Boiler Temperature", + 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", + 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", + 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", + 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", + 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", + 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 1 Temperature", + 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 2 Temperature", + 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="Buffer Load", + 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, ), @@ -127,7 +145,7 @@ def __init__( self._name = entity_description.key self._attr_name = entity_description.key - serial = coordinator.data["Serial"][0] + serial = coordinator.data["serial"][0] self._attr_unique_id = ( f"{serial.replace('.', '_')}_{entity_description.key.replace(' ', '_')}" @@ -138,7 +156,7 @@ def __init__( name="Guntamatic Heater", manufacturer="Guntamatic", serial_number=serial, - sw_version=coordinator.data["Version"][0], + sw_version=coordinator.data["version"][0], ) @property diff --git a/homeassistant/components/guntamatic/strings.json b/homeassistant/components/guntamatic/strings.json index 6eac149f14fbd..e4150c74fff35 100644 --- a/homeassistant/components/guntamatic/strings.json +++ b/homeassistant/components/guntamatic/strings.json @@ -18,5 +18,54 @@ } } } + }, + "entity": { + "sensor": { + "boiler_temperature": { + "name": "Boiler Temperature" + }, + "buffer_bottem_temperature": { + "name": "Buffer Bottem Temperature" + }, + "buffer_center_temperature": { + "name": "Buffer Center Temperature" + }, + "buffer_load": { + "name": "Buffer Load" + }, + "buffer_top_temperature": { + "name": "Buffer Top Temperature" + }, + "domestic_home_water_temperature": { + "name": "Domestic Home Water 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" + } + } } } diff --git a/requirements_all.txt b/requirements_all.txt index e6a9a7d2649af..2158fc13977c3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1161,7 +1161,7 @@ growattServer==1.9.0 gspread==5.5.0 # homeassistant.components.guntamatic -guntamatic==1.3.0 +guntamatic==1.5.0 # homeassistant.components.profiler guppy3==3.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 34f2bed497ff6..8b2155ad6cef4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1034,7 +1034,7 @@ growattServer==1.9.0 gspread==5.5.0 # homeassistant.components.guntamatic -guntamatic==1.3.0 +guntamatic==1.5.0 # homeassistant.components.profiler guppy3==3.1.6 diff --git a/tests/components/guntamatic/conftest.py b/tests/components/guntamatic/conftest.py index 4a82ff6c692fb..3b7f9b6d50608 100644 --- a/tests/components/guntamatic/conftest.py +++ b/tests/components/guntamatic/conftest.py @@ -21,6 +21,16 @@ "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]: @@ -30,7 +40,7 @@ def mock_heater() -> Generator[MagicMock]: autospec=True, ) as mock: mock.return_value.get_data = MagicMock(return_value=MOCK_DATA) - mock.return_value.parse_data = MagicMock(return_value=MOCK_DATA) + mock.return_value.parse_data = MagicMock(return_value=MOCK_PARSE_DATA) mock.return_value.host = "1.1.1.1" yield mock diff --git a/tests/components/guntamatic/snapshots/test_sensor.ambr b/tests/components/guntamatic/snapshots/test_sensor.ambr index 8ff6b25b3599e..d19070d55e75e 100644 --- a/tests/components/guntamatic/snapshots/test_sensor.ambr +++ b/tests/components/guntamatic/snapshots/test_sensor.ambr @@ -23,7 +23,7 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Boiler Temperature', + 'object_id_base': 'boiler_temperature', 'options': dict({ 'sensor': dict({ 'suggested_display_precision': 1, @@ -31,13 +31,13 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Boiler Temperature', + 'original_name': 'boiler_temperature', 'platform': 'guntamatic', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '959103_Boiler_Temperature', + 'translation_key': 'boiler_temperature', + 'unique_id': '959103_boiler_temperature', 'unit_of_measurement': , }) # --- @@ -45,7 +45,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Guntamatic Heater Boiler Temperature', + 'friendly_name': 'Guntamatic Heater boiler_temperature', 'state_class': , 'unit_of_measurement': , }), @@ -81,25 +81,25 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Buffer Load', + 'object_id_base': 'buffer_load', 'options': dict({ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Buffer Load', + 'original_name': 'buffer_load', 'platform': 'guntamatic', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '959103_Buffer_Load', + 'translation_key': 'buffer_load', + 'unique_id': '959103_buffer_load', 'unit_of_measurement': '%', }) # --- # name: test_all_entities[sensor.guntamatic_heater_buffer_load-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Guntamatic Heater Buffer Load', + 'friendly_name': 'Guntamatic Heater buffer_load', 'state_class': , 'unit_of_measurement': '%', }), @@ -135,7 +135,7 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Outdoor Temperature', + 'object_id_base': 'outdoor_temperature', 'options': dict({ 'sensor': dict({ 'suggested_display_precision': 1, @@ -143,13 +143,13 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Outdoor Temperature', + 'original_name': 'outdoor_temperature', 'platform': 'guntamatic', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '959103_Outdoor_Temperature', + 'translation_key': 'outdoor_temperature', + 'unique_id': '959103_outdoor_temperature', 'unit_of_measurement': , }) # --- @@ -157,7 +157,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Guntamatic Heater Outdoor Temperature', + 'friendly_name': 'Guntamatic Heater outdoor_temperature', 'state_class': , 'unit_of_measurement': , }), @@ -177,13 +177,13 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ - 'OFF', - 'TIMER', - 'DHW', - 'HEAT', - 'HIBERNAT', - 'HIBERNATE TO', - 'DHW BOOST', + 'off', + 'timer', + 'dhw', + 'heat', + 'hibernate', + 'hibernate_to', + 'dhw_boost', ]), }), 'config_entry_id': , @@ -201,18 +201,18 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Program', + 'object_id_base': 'program', 'options': dict({ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Program', + 'original_name': 'program', 'platform': 'guntamatic', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '959103_Program', + 'translation_key': 'program', + 'unique_id': '959103_program', 'unit_of_measurement': None, }) # --- @@ -220,15 +220,15 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', - 'friendly_name': 'Guntamatic Heater Program', + 'friendly_name': 'Guntamatic Heater program', 'options': list([ - 'OFF', - 'TIMER', - 'DHW', - 'HEAT', - 'HIBERNAT', - 'HIBERNATE TO', - 'DHW BOOST', + 'off', + 'timer', + 'dhw', + 'heat', + 'hibernate', + 'hibernate_to', + 'dhw_boost', ]), }), 'context': , @@ -236,7 +236,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'HEAT', + 'state': 'heat', }) # --- # name: test_all_entities[sensor.guntamatic_heater_status-entry] @@ -261,25 +261,25 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Status', + 'object_id_base': 'status', 'options': dict({ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Status', + 'original_name': 'status', 'platform': 'guntamatic', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '959103_Status', + 'translation_key': 'status', + 'unique_id': '959103_status', 'unit_of_measurement': None, }) # --- # name: test_all_entities[sensor.guntamatic_heater_status-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Guntamatic Heater Status', + 'friendly_name': 'Guntamatic Heater status', }), 'context': , 'entity_id': 'sensor.guntamatic_heater_status', diff --git a/tests/components/guntamatic/test_coordinator.py b/tests/components/guntamatic/test_coordinator.py index 9d6d0237f9aea..b9d0469f6acf3 100644 --- a/tests/components/guntamatic/test_coordinator.py +++ b/tests/components/guntamatic/test_coordinator.py @@ -10,7 +10,7 @@ from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from .conftest import MOCK_DATA +from .conftest import MOCK_PARSE_DATA from tests.common import MockConfigEntry @@ -44,7 +44,7 @@ async def test_coordinator_update_failed( # Recovery mock_heater.return_value.parse_data.side_effect = None - mock_heater.return_value.parse_data.return_value = MOCK_DATA + mock_heater.return_value.parse_data.return_value = MOCK_PARSE_DATA await mock_config_entry.runtime_data.async_refresh() await hass.async_block_till_done() state = hass.states.get("sensor.guntamatic_heater_boiler_temperature") From 8e5cbc936146e7741ca9d742d25146a977b4ef23 Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Sun, 19 Apr 2026 23:14:09 +0200 Subject: [PATCH 30/43] Use serial in discovery, seperate confirm step --- .../components/guntamatic/config_flow.py | 33 +++++++---- .../components/guntamatic/test_config_flow.py | 55 ++++++++++--------- 2 files changed, 52 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/guntamatic/config_flow.py b/homeassistant/components/guntamatic/config_flow.py index 88777dad4e4a7..b84d5facce884 100644 --- a/homeassistant/components/guntamatic/config_flow.py +++ b/homeassistant/components/guntamatic/config_flow.py @@ -33,16 +33,27 @@ async def async_step_dhcp( self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle DHCP discovery.""" - # we don't have access to serial yet here without doing a network call to the device - # so dedupe on MAC address here, we will overwrite with serial in the next step - await self.async_set_unique_id(discovery_info.macaddress) - self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) - return self.async_show_form( - step_id="user", - data_schema=self.add_suggested_values_to_schema( - STEP_USER_DATA_SCHEMA, {CONF_HOST: discovery_info.ip} - ), - ) + heater = Heater(discovery_info.ip) + data = await self.hass.async_add_executor_job(heater.parse_data) + # set serial as unique id for deduplication, ip isn't a good match + serial = data["serial"][0] + await self.async_set_unique_id(serial) + self._abort_if_unique_id_configured() + + 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="Gunamatic Heater", + data={CONF_HOST: user_input[CONF_HOST]}, + ) + + 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 @@ -64,7 +75,7 @@ async def async_step_user( errors["base"] = "unknown" else: # set serial as unique id for deduplication, ip isn't a good match - serial = data["Serial"][0] + serial = data["serial"][0] await self.async_set_unique_id(serial) self._abort_if_unique_id_configured() diff --git a/tests/components/guntamatic/test_config_flow.py b/tests/components/guntamatic/test_config_flow.py index b75699f0fcc29..2b620b9d3af5a 100644 --- a/tests/components/guntamatic/test_config_flow.py +++ b/tests/components/guntamatic/test_config_flow.py @@ -13,7 +13,7 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .conftest import MOCK_DATA +from .conftest import MOCK_DATA, MOCK_PARSE_DATA from tests.common import MockConfigEntry @@ -27,7 +27,7 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: assert result["errors"] == {} with patch( - "guntamatic.heater.Heater.parse_data", + "guntamatic.heater.Heater.get_data", return_value=MOCK_DATA, ): result = await hass.config_entries.flow.async_configure( @@ -75,7 +75,7 @@ async def test_form_errors( # Recover from error with patch( "guntamatic.heater.Heater.parse_data", - return_value=MOCK_DATA, + return_value=MOCK_PARSE_DATA, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -102,7 +102,7 @@ async def test_form_already_configured( ) with patch( "guntamatic.heater.Heater.parse_data", - return_value=MOCK_DATA, + return_value=MOCK_PARSE_DATA, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -115,38 +115,43 @@ async def test_form_already_configured( async def test_dhcp_discovery(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test DHCP discovery shows confirmation form.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=DhcpServiceInfo( - ip="1.1.1.1", - hostname="kessel0001", - macaddress="0024bd123456", - ), - ) - await hass.async_block_till_done() + with patch( + "guntamatic.heater.Heater.parse_data", + return_value=MOCK_PARSE_DATA, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="1.1.1.1", + hostname="kessel0001", + macaddress="0024bd123456", + ), + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "discovery_confirm" async def test_dhcp_discovery_confirm( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test DHCP discovery confirmation creates entry.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=DhcpServiceInfo( - ip="1.1.1.1", - hostname="kessel0001", - macaddress="0024bd123456", - ), - ) + with patch( "guntamatic.heater.Heater.parse_data", - return_value=MOCK_DATA, + return_value=MOCK_PARSE_DATA, ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="1.1.1.1", + hostname="kessel0001", + macaddress="0024bd123456", + ), + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "1.1.1.1"}, From 75eea537aabf2f422715ee32ae67dfeca871420f Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Sun, 19 Apr 2026 23:16:58 +0200 Subject: [PATCH 31/43] Use serial in discovery, seperate confirm step --- homeassistant/components/guntamatic/config_flow.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/guntamatic/config_flow.py b/homeassistant/components/guntamatic/config_flow.py index b84d5facce884..cf1810902efa1 100644 --- a/homeassistant/components/guntamatic/config_flow.py +++ b/homeassistant/components/guntamatic/config_flow.py @@ -33,8 +33,14 @@ async def async_step_dhcp( self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle DHCP discovery.""" - heater = Heater(discovery_info.ip) - data = await self.hass.async_add_executor_job(heater.parse_data) + try: + heater = Heater(discovery_info.ip) + 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") + # set serial as unique id for deduplication, ip isn't a good match serial = data["serial"][0] await self.async_set_unique_id(serial) From 32006fee94cdd5f7fe6b3e1cf8e70f002fcce931 Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:03:29 +0200 Subject: [PATCH 32/43] Added tests --- .../components/guntamatic/config_flow.py | 6 +- homeassistant/components/guntamatic/sensor.py | 10 +-- .../components/guntamatic/strings.json | 6 +- .../guntamatic/snapshots/test_sensor.ambr | 70 +++++++++---------- .../components/guntamatic/test_config_flow.py | 33 +++++++++ .../components/guntamatic/test_coordinator.py | 4 +- 6 files changed, 80 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/guntamatic/config_flow.py b/homeassistant/components/guntamatic/config_flow.py index cf1810902efa1..6b2f0086146dc 100644 --- a/homeassistant/components/guntamatic/config_flow.py +++ b/homeassistant/components/guntamatic/config_flow.py @@ -28,6 +28,7 @@ class GuntamaticConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for guntamatic.""" VERSION = 1 + _discovered_ip: str async def async_step_dhcp( self, discovery_info: DhcpServiceInfo @@ -45,6 +46,7 @@ async def async_step_dhcp( serial = data["serial"][0] await self.async_set_unique_id(serial) self._abort_if_unique_id_configured() + self._discovered_ip = discovery_info.ip return await self.async_step_discovery_confirm() @@ -54,8 +56,8 @@ async def async_step_discovery_confirm( """Confirm discovery.""" if user_input is not None: return self.async_create_entry( - title="Gunamatic Heater", - data={CONF_HOST: user_input[CONF_HOST]}, + title="Guntamatic Heater", + data={CONF_HOST: self._discovered_ip}, ) self._set_confirm_only() diff --git a/homeassistant/components/guntamatic/sensor.py b/homeassistant/components/guntamatic/sensor.py index 654b9703d57ee..5bba0c675703b 100644 --- a/homeassistant/components/guntamatic/sensor.py +++ b/homeassistant/components/guntamatic/sensor.py @@ -142,18 +142,12 @@ def __init__( super().__init__(coordinator) self.entity_description = entity_description - self._name = entity_description.key - self._attr_name = entity_description.key - serial = coordinator.data["serial"][0] - self._attr_unique_id = ( - f"{serial.replace('.', '_')}_{entity_description.key.replace(' ', '_')}" - ) + self._attr_unique_id = f"{serial.replace('.', '_')}_{entity_description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, serial)}, - name="Guntamatic Heater", manufacturer="Guntamatic", serial_number=serial, sw_version=coordinator.data["version"][0], @@ -162,4 +156,4 @@ def __init__( @property def native_value(self) -> StateType: """Return the current value of the sensor.""" - return self.coordinator.data[self._name][0] + return self.coordinator.data[self.entity_description.key][0] diff --git a/homeassistant/components/guntamatic/strings.json b/homeassistant/components/guntamatic/strings.json index e4150c74fff35..a223445034c7a 100644 --- a/homeassistant/components/guntamatic/strings.json +++ b/homeassistant/components/guntamatic/strings.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "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.", @@ -24,7 +26,7 @@ "boiler_temperature": { "name": "Boiler Temperature" }, - "buffer_bottem_temperature": { + "buffer_bottom_temperature": { "name": "Buffer Bottem Temperature" }, "buffer_center_temperature": { diff --git a/tests/components/guntamatic/snapshots/test_sensor.ambr b/tests/components/guntamatic/snapshots/test_sensor.ambr index d19070d55e75e..1fc7454c07790 100644 --- a/tests/components/guntamatic/snapshots/test_sensor.ambr +++ b/tests/components/guntamatic/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_all_entities[sensor.guntamatic_heater_boiler_temperature-entry] +# name: test_all_entities[sensor.mock_title_boiler_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -15,7 +15,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.guntamatic_heater_boiler_temperature', + 'entity_id': 'sensor.mock_title_boiler_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -23,7 +23,7 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'boiler_temperature', + 'object_id_base': 'Boiler Temperature', 'options': dict({ 'sensor': dict({ 'suggested_display_precision': 1, @@ -31,7 +31,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'boiler_temperature', + 'original_name': 'Boiler Temperature', 'platform': 'guntamatic', 'previous_unique_id': None, 'suggested_object_id': None, @@ -41,23 +41,23 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.guntamatic_heater_boiler_temperature-state] +# name: test_all_entities[sensor.mock_title_boiler_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Guntamatic Heater boiler_temperature', + 'friendly_name': 'Mock Title Boiler Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.guntamatic_heater_boiler_temperature', + 'entity_id': 'sensor.mock_title_boiler_temperature', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '14.09', }) # --- -# name: test_all_entities[sensor.guntamatic_heater_buffer_load-entry] +# name: test_all_entities[sensor.mock_title_buffer_load-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -73,7 +73,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.guntamatic_heater_buffer_load', + 'entity_id': 'sensor.mock_title_buffer_load', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -81,12 +81,12 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'buffer_load', + 'object_id_base': 'Buffer Load', 'options': dict({ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'buffer_load', + 'original_name': 'Buffer Load', 'platform': 'guntamatic', 'previous_unique_id': None, 'suggested_object_id': None, @@ -96,22 +96,22 @@ 'unit_of_measurement': '%', }) # --- -# name: test_all_entities[sensor.guntamatic_heater_buffer_load-state] +# name: test_all_entities[sensor.mock_title_buffer_load-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Guntamatic Heater buffer_load', + 'friendly_name': 'Mock Title Buffer Load', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.guntamatic_heater_buffer_load', + 'entity_id': 'sensor.mock_title_buffer_load', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '22', }) # --- -# name: test_all_entities[sensor.guntamatic_heater_outdoor_temperature-entry] +# name: test_all_entities[sensor.mock_title_outdoor_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -127,7 +127,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.guntamatic_heater_outdoor_temperature', + 'entity_id': 'sensor.mock_title_outdoor_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -135,7 +135,7 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'outdoor_temperature', + 'object_id_base': 'Outdoor Temperature', 'options': dict({ 'sensor': dict({ 'suggested_display_precision': 1, @@ -143,7 +143,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'outdoor_temperature', + 'original_name': 'Outdoor Temperature', 'platform': 'guntamatic', 'previous_unique_id': None, 'suggested_object_id': None, @@ -153,23 +153,23 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.guntamatic_heater_outdoor_temperature-state] +# name: test_all_entities[sensor.mock_title_outdoor_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Guntamatic Heater outdoor_temperature', + 'friendly_name': 'Mock Title Outdoor Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.guntamatic_heater_outdoor_temperature', + 'entity_id': 'sensor.mock_title_outdoor_temperature', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '15.95', }) # --- -# name: test_all_entities[sensor.guntamatic_heater_program-entry] +# name: test_all_entities[sensor.mock_title_program-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -193,7 +193,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.guntamatic_heater_program', + 'entity_id': 'sensor.mock_title_program', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -201,12 +201,12 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'program', + 'object_id_base': 'Program', 'options': dict({ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'program', + 'original_name': 'Program', 'platform': 'guntamatic', 'previous_unique_id': None, 'suggested_object_id': None, @@ -216,11 +216,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.guntamatic_heater_program-state] +# name: test_all_entities[sensor.mock_title_program-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', - 'friendly_name': 'Guntamatic Heater program', + 'friendly_name': 'Mock Title Program', 'options': list([ 'off', 'timer', @@ -232,14 +232,14 @@ ]), }), 'context': , - 'entity_id': 'sensor.guntamatic_heater_program', + 'entity_id': 'sensor.mock_title_program', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'heat', }) # --- -# name: test_all_entities[sensor.guntamatic_heater_status-entry] +# name: test_all_entities[sensor.mock_title_status-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -253,7 +253,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.guntamatic_heater_status', + 'entity_id': 'sensor.mock_title_status', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -261,12 +261,12 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'status', + 'object_id_base': 'Status', 'options': dict({ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'status', + 'original_name': 'Status', 'platform': 'guntamatic', 'previous_unique_id': None, 'suggested_object_id': None, @@ -276,13 +276,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.guntamatic_heater_status-state] +# name: test_all_entities[sensor.mock_title_status-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Guntamatic Heater status', + 'friendly_name': 'Mock Title Status', }), 'context': , - 'entity_id': 'sensor.guntamatic_heater_status', + 'entity_id': 'sensor.mock_title_status', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/guntamatic/test_config_flow.py b/tests/components/guntamatic/test_config_flow.py index 2b620b9d3af5a..557e62813a587 100644 --- a/tests/components/guntamatic/test_config_flow.py +++ b/tests/components/guntamatic/test_config_flow.py @@ -134,6 +134,39 @@ async def test_dhcp_discovery(hass: HomeAssistant, mock_setup_entry: AsyncMock) assert result["step_id"] == "discovery_confirm" +@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, +): + """Test DHCP discovery shows confirmation form.""" + with patch( + "guntamatic.heater.Heater.parse_data", + side_effect=side_effect, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="1.1.1.1", + hostname="kessel0001", + macaddress="0024bd123456", + ), + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_error + + async def test_dhcp_discovery_confirm( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: diff --git a/tests/components/guntamatic/test_coordinator.py b/tests/components/guntamatic/test_coordinator.py index b9d0469f6acf3..bc0f490212ff1 100644 --- a/tests/components/guntamatic/test_coordinator.py +++ b/tests/components/guntamatic/test_coordinator.py @@ -39,7 +39,7 @@ async def test_coordinator_update_failed( await mock_config_entry.runtime_data.async_refresh() await hass.async_block_till_done() - state = hass.states.get("sensor.guntamatic_heater_boiler_temperature") + state = hass.states.get("sensor.mock_title_boiler_temperature") assert state.state == STATE_UNAVAILABLE # Recovery @@ -47,5 +47,5 @@ async def test_coordinator_update_failed( mock_heater.return_value.parse_data.return_value = MOCK_PARSE_DATA await mock_config_entry.runtime_data.async_refresh() await hass.async_block_till_done() - state = hass.states.get("sensor.guntamatic_heater_boiler_temperature") + state = hass.states.get("sensor.mock_title_boiler_temperature") assert state.state != STATE_UNAVAILABLE From f08775fc5e5503c9a5fbdc3db1f76afd342dbb03 Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:27:13 +0200 Subject: [PATCH 33/43] Fix return type in tests --- tests/components/guntamatic/test_config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/guntamatic/test_config_flow.py b/tests/components/guntamatic/test_config_flow.py index 557e62813a587..3b685fdf2a492 100644 --- a/tests/components/guntamatic/test_config_flow.py +++ b/tests/components/guntamatic/test_config_flow.py @@ -146,7 +146,7 @@ async def test_dhcp_discovery_errors( mock_setup_entry: AsyncMock, side_effect: Exception, expected_error: str, -): +) -> None: """Test DHCP discovery shows confirmation form.""" with patch( "guntamatic.heater.Heater.parse_data", From 80ddaf8ff427fc15ca868f1b6b26ef1204b77d75 Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:52:28 +0200 Subject: [PATCH 34/43] Removed unneeded async_setup --- homeassistant/components/guntamatic/coordinator.py | 12 +----------- tests/components/guntamatic/test_config_flow.py | 4 ++-- tests/components/guntamatic/test_init.py | 2 -- 3 files changed, 3 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/guntamatic/coordinator.py b/homeassistant/components/guntamatic/coordinator.py index 7c3de3fd2da61..07f135622ce6b 100644 --- a/homeassistant/components/guntamatic/coordinator.py +++ b/homeassistant/components/guntamatic/coordinator.py @@ -4,12 +4,11 @@ import logging -from guntamatic.heater import Heater, NoSerialException +from guntamatic.heater import Heater import requests from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, SCAN_INTERVAL @@ -35,15 +34,6 @@ def __init__( ) self.heater = heater - async def _async_setup(self) -> None: - """Do initialization logic.""" - try: - await self.hass.async_add_executor_job(self.heater.parse_data) - except NoSerialException as err: - raise ConfigEntryError(f"Unexpected data from heater: {err}") from err - except requests.exceptions.ConnectionError as err: - raise ConfigEntryNotReady(f"Cannot connect to heater: {err}") from err - async def _async_update_data(self) -> dict[str, list[str]]: """Fetch data from heater.""" try: diff --git a/tests/components/guntamatic/test_config_flow.py b/tests/components/guntamatic/test_config_flow.py index 3b685fdf2a492..1a738d831f205 100644 --- a/tests/components/guntamatic/test_config_flow.py +++ b/tests/components/guntamatic/test_config_flow.py @@ -27,8 +27,8 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: assert result["errors"] == {} with patch( - "guntamatic.heater.Heater.get_data", - return_value=MOCK_DATA, + "guntamatic.heater.Heater.parse_data", + return_value=MOCK_PARSE_DATA, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/guntamatic/test_init.py b/tests/components/guntamatic/test_init.py index f564ab7cb58e6..2c5307daa08c7 100644 --- a/tests/components/guntamatic/test_init.py +++ b/tests/components/guntamatic/test_init.py @@ -2,7 +2,6 @@ from unittest.mock import MagicMock -from guntamatic.heater import NoSerialException import pytest import requests @@ -31,7 +30,6 @@ async def test_setup_entry( requests.exceptions.ConnectionError("Cannot connect"), ConfigEntryState.SETUP_RETRY, ), - (NoSerialException, ConfigEntryState.SETUP_ERROR), ], ) async def test_setup_entry_fails( From cf4787ea51abc89e508bfcc1fbb77e507f7743ac Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:23:14 +0200 Subject: [PATCH 35/43] updated quality scale --- homeassistant/components/guntamatic/__init__.py | 5 +---- .../components/guntamatic/config_flow.py | 8 +++----- homeassistant/components/guntamatic/manifest.json | 4 +++- .../components/guntamatic/quality_scale.yaml | 12 +++++++----- homeassistant/components/guntamatic/sensor.py | 15 +++++++-------- homeassistant/generated/dhcp.py | 4 ++++ 6 files changed, 25 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/guntamatic/__init__.py b/homeassistant/components/guntamatic/__init__.py index e3db98c3ed41a..948be23787c02 100644 --- a/homeassistant/components/guntamatic/__init__.py +++ b/homeassistant/components/guntamatic/__init__.py @@ -17,10 +17,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GuntamaticConfigEntry) -> bool: """Set up guntamatic from a config entry.""" - - heater = Heater(entry.data[CONF_HOST]) - - coordinator = GuntamaticCoordinator(hass, heater, entry) + coordinator = GuntamaticCoordinator(hass, Heater(entry.data[CONF_HOST]), entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/guntamatic/config_flow.py b/homeassistant/components/guntamatic/config_flow.py index 6b2f0086146dc..8c809cd29b15d 100644 --- a/homeassistant/components/guntamatic/config_flow.py +++ b/homeassistant/components/guntamatic/config_flow.py @@ -43,9 +43,8 @@ async def async_step_dhcp( return self.async_abort(reason="bad_data") # set serial as unique id for deduplication, ip isn't a good match - serial = data["serial"][0] - await self.async_set_unique_id(serial) - self._abort_if_unique_id_configured() + 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() @@ -83,8 +82,7 @@ async def async_step_user( errors["base"] = "unknown" else: # set serial as unique id for deduplication, ip isn't a good match - serial = data["serial"][0] - await self.async_set_unique_id(serial) + await self.async_set_unique_id(data["serial"][0]) self._abort_if_unique_id_configured() return self.async_create_entry( diff --git a/homeassistant/components/guntamatic/manifest.json b/homeassistant/components/guntamatic/manifest.json index 0f7ddb6477d81..58d3568862ffb 100644 --- a/homeassistant/components/guntamatic/manifest.json +++ b/homeassistant/components/guntamatic/manifest.json @@ -8,9 +8,11 @@ { "hostname": "kessel*", "macaddress": "0024BD*" + }, + { + "registered_devices": true } ], - "documentation": "https://www.home-assistant.io/integrations/guntamatic", "integration_type": "device", "iot_class": "local_polling", diff --git a/homeassistant/components/guntamatic/quality_scale.yaml b/homeassistant/components/guntamatic/quality_scale.yaml index 14d731fbf14cf..bc26329ff7944 100644 --- a/homeassistant/components/guntamatic/quality_scale.yaml +++ b/homeassistant/components/guntamatic/quality_scale.yaml @@ -44,8 +44,8 @@ rules: # Gold devices: done diagnostics: todo - discovery-update-info: todo - discovery: todo + discovery-update-info: done + discovery: done docs-data-update: todo docs-examples: todo docs-known-limitations: todo @@ -53,11 +53,13 @@ rules: docs-supported-functions: todo docs-troubleshooting: todo docs-use-cases: todo - dynamic-devices: done + dynamic-devices: + status: exempt + comment: Single device entity-category: done - entity-device-class: todo + entity-device-class: done entity-disabled-by-default: todo - entity-translations: todo + entity-translations: done exception-translations: todo icon-translations: todo reconfiguration-flow: todo diff --git a/homeassistant/components/guntamatic/sensor.py b/homeassistant/components/guntamatic/sensor.py index 5bba0c675703b..d45c78c2fa0f7 100644 --- a/homeassistant/components/guntamatic/sensor.py +++ b/homeassistant/components/guntamatic/sensor.py @@ -116,16 +116,15 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Guntamatic sensors from config entry.""" - coordinator = entry.runtime_data - sensors = [ - GuntamaticSensor(coordinator, description) - for description in GUNTAMATIC_SENSORS - if description.key in coordinator.data - ] - - async_add_entities(sensors) + async_add_entities( + [ + GuntamaticSensor(coordinator, description) + for description in GUNTAMATIC_SENSORS + if description.key in coordinator.data + ] + ) class GuntamaticSensor(CoordinatorEntity[GuntamaticCoordinator], SensorEntity): diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 1e68e1af9d0ef..7590471a94bfb 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -302,6 +302,10 @@ "hostname": "kessel*", "macaddress": "0024BD*", }, + { + "domain": "guntamatic", + "registered_devices": True, + }, { "domain": "home_connect", "hostname": "balay-*", From a7764344ff21d41068ca93718913dc2c5f1aac83 Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Tue, 21 Apr 2026 21:51:18 +0200 Subject: [PATCH 36/43] Fixed remarks in pr --- homeassistant/components/guntamatic/config_flow.py | 3 +-- tests/components/guntamatic/test_config_flow.py | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/guntamatic/config_flow.py b/homeassistant/components/guntamatic/config_flow.py index 8c809cd29b15d..2c5191f2dddfc 100644 --- a/homeassistant/components/guntamatic/config_flow.py +++ b/homeassistant/components/guntamatic/config_flow.py @@ -27,15 +27,14 @@ class GuntamaticConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for guntamatic.""" - VERSION = 1 _discovered_ip: str async def async_step_dhcp( self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle DHCP discovery.""" + heater = Heater(discovery_info.ip) try: - heater = Heater(discovery_info.ip) data = await self.hass.async_add_executor_job(heater.parse_data) except requests.exceptions.RequestException: return self.async_abort(reason="cannot_connect") diff --git a/tests/components/guntamatic/test_config_flow.py b/tests/components/guntamatic/test_config_flow.py index 1a738d831f205..fe97d0e41afb7 100644 --- a/tests/components/guntamatic/test_config_flow.py +++ b/tests/components/guntamatic/test_config_flow.py @@ -34,7 +34,6 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: result["flow_id"], {CONF_HOST: "1.1.1.1"}, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Guntamatic Heater" @@ -81,7 +80,6 @@ async def test_form_errors( result["flow_id"], {CONF_HOST: "1.1.1.1"}, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY From 1d47520e8c1146034bf963148200bfd25124fa9d Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Sat, 25 Apr 2026 00:55:13 +0200 Subject: [PATCH 37/43] Cleanup tests --- tests/components/guntamatic/__init__.py | 1 + tests/components/guntamatic/conftest.py | 25 ++- .../components/guntamatic/test_config_flow.py | 200 ++++++++++-------- .../components/guntamatic/test_coordinator.py | 52 ++--- tests/components/guntamatic/test_init.py | 27 +-- tests/components/guntamatic/test_sensor.py | 51 ++++- 6 files changed, 205 insertions(+), 151 deletions(-) diff --git a/tests/components/guntamatic/__init__.py b/tests/components/guntamatic/__init__.py index e91bc419c1828..d4bb45c713fdb 100644 --- a/tests/components/guntamatic/__init__.py +++ b/tests/components/guntamatic/__init__.py @@ -13,3 +13,4 @@ async def setup_integration( config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + return config_entry diff --git a/tests/components/guntamatic/conftest.py b/tests/components/guntamatic/conftest.py index 3b7f9b6d50608..214d38c206ee6 100644 --- a/tests/components/guntamatic/conftest.py +++ b/tests/components/guntamatic/conftest.py @@ -7,7 +7,6 @@ from homeassistant.components.guntamatic.const import DOMAIN from homeassistant.const import CONF_HOST -from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -35,22 +34,28 @@ @pytest.fixture def mock_heater() -> Generator[MagicMock]: """Mock the Heater class.""" - with patch( - "homeassistant.components.guntamatic.Heater", - autospec=True, - ) as mock: - mock.return_value.get_data = MagicMock(return_value=MOCK_DATA) - mock.return_value.parse_data = MagicMock(return_value=MOCK_PARSE_DATA) - mock.return_value.host = "1.1.1.1" - yield mock + with ( + patch( + "guntamatic.heater.Heater", + autospec=True, + ) as mock, + patch("homeassistant.components.guntamatic.Heater", new=mock), + patch("homeassistant.components.guntamatic.coordinator.Heater", new=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(hass: HomeAssistant) -> MockConfigEntry: +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], ) diff --git a/tests/components/guntamatic/test_config_flow.py b/tests/components/guntamatic/test_config_flow.py index fe97d0e41afb7..626871da061ba 100644 --- a/tests/components/guntamatic/test_config_flow.py +++ b/tests/components/guntamatic/test_config_flow.py @@ -1,6 +1,6 @@ """Test the guntamatic config flow.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock from guntamatic.heater import NoSerialException import pytest @@ -8,6 +8,7 @@ from homeassistant import config_entries from homeassistant.components.guntamatic.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -18,26 +19,28 @@ from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: +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": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with patch( - "guntamatic.heater.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"}, - ) + 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 @@ -54,79 +57,69 @@ async def test_form_errors( 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": config_entries.SOURCE_USER} + 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"}, ) - with patch( - "guntamatic.heater.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 - with patch( - "guntamatic.heater.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"}, - ) + 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 + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_heater: MagicMock, + mock_config_entry: MagicMock, ) -> None: """Test we abort if already configured.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: "1.1.1.1"}, - unique_id=MOCK_DATA["Serial"][0], - ) - entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1"}, ) - with patch( - "guntamatic.heater.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.ABORT assert result["reason"] == "already_configured" -async def test_dhcp_discovery(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: +async def test_dhcp_discovery( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_heater: MagicMock, +) -> None: """Test DHCP discovery shows confirmation form.""" - with patch( - "guntamatic.heater.Heater.parse_data", - return_value=MOCK_PARSE_DATA, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=DhcpServiceInfo( - ip="1.1.1.1", - hostname="kessel0001", - macaddress="0024bd123456", - ), - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="1.1.1.1", + hostname="kessel0001", + macaddress="0024bd123456", + ), + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM assert result["step_id"] == "discovery_confirm" @@ -144,50 +137,73 @@ async def test_dhcp_discovery_errors( mock_setup_entry: AsyncMock, side_effect: Exception, expected_error: str, + mock_heater: MagicMock, ) -> None: """Test DHCP discovery shows confirmation form.""" - with patch( - "guntamatic.heater.Heater.parse_data", - side_effect=side_effect, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=DhcpServiceInfo( - ip="1.1.1.1", - hostname="kessel0001", - macaddress="0024bd123456", - ), - ) - await hass.async_block_till_done() + mock_heater.parse_data.side_effect = (side_effect,) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="1.1.1.1", + hostname="kessel0001", + macaddress="0024bd123456", + ), + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == expected_error async def test_dhcp_discovery_confirm( - hass: HomeAssistant, mock_setup_entry: AsyncMock + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_heater: MagicMock, ) -> None: """Test DHCP discovery confirmation creates entry.""" - with patch( - "guntamatic.heater.Heater.parse_data", - return_value=MOCK_PARSE_DATA, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=DhcpServiceInfo( - ip="1.1.1.1", - hostname="kessel0001", - macaddress="0024bd123456", - ), - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: "1.1.1.1"}, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="1.1.1.1", + hostname="kessel0001", + macaddress="0024bd123456", + ), + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1"}, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == {CONF_HOST: "1.1.1.1"} + + +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) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="1.1.1.2", + hostname="kessel0001", + macaddress="0024bd123456", + ), + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_HOST] == "1.1.1.2" diff --git a/tests/components/guntamatic/test_coordinator.py b/tests/components/guntamatic/test_coordinator.py index bc0f490212ff1..615fe49830da8 100644 --- a/tests/components/guntamatic/test_coordinator.py +++ b/tests/components/guntamatic/test_coordinator.py @@ -2,50 +2,50 @@ from unittest.mock import MagicMock -from guntamatic.heater import NoSerialException -import pytest +from freezegun.api import FrozenDateTimeFactory import requests -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.components.guntamatic.const import SCAN_INTERVAL from homeassistant.core import HomeAssistant from .conftest import MOCK_PARSE_DATA -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed -@pytest.mark.parametrize( - "side_effect", - [ - requests.exceptions.ConnectionError("Connection lost"), - NoSerialException, - Exception("Unknown error"), - ], -) async def test_coordinator_update_failed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_heater: MagicMock, - side_effect: Exception, + freezer: FrozenDateTimeFactory, ) -> None: - """Test coordinator handles update failures.""" + """Test coordinator raises UpdateFailed on connection error.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert mock_config_entry.state is ConfigEntryState.LOADED - mock_heater.return_value.parse_data.side_effect = side_effect - await mock_config_entry.runtime_data.async_refresh() + mock_heater.parse_data.side_effect = ( + requests.exceptions.ConnectionError("Connection lost"), + ) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() + assert mock_config_entry.runtime_data.last_update_success is False + - state = hass.states.get("sensor.mock_title_boiler_temperature") - assert state.state == STATE_UNAVAILABLE +async def test_coordinator_returns_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_heater: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test coordinator returns correct data.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() - # Recovery - mock_heater.return_value.parse_data.side_effect = None - mock_heater.return_value.parse_data.return_value = MOCK_PARSE_DATA - await mock_config_entry.runtime_data.async_refresh() + 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 + assert mock_config_entry.runtime_data.last_update_success is True + assert mock_config_entry.runtime_data.data == MOCK_PARSE_DATA diff --git a/tests/components/guntamatic/test_init.py b/tests/components/guntamatic/test_init.py index 2c5307daa08c7..93e2fbc165010 100644 --- a/tests/components/guntamatic/test_init.py +++ b/tests/components/guntamatic/test_init.py @@ -8,6 +8,8 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from . import setup_integration + from tests.common import MockConfigEntry @@ -17,10 +19,10 @@ async def test_setup_entry( mock_heater: MagicMock, ) -> None: """Test successful setup of the integration.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + 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( @@ -40,21 +42,6 @@ async def test_setup_entry_fails( expected_state: ConfigEntryState, ) -> None: """Test setup fails correctly for different error types.""" - mock_heater.return_value.parse_data.side_effect = side_effect - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + mock_heater.parse_data.side_effect = side_effect + await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is expected_state - - -async def test_unload_entry( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_heater: MagicMock, -) -> None: - """Test unloading the integration.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - assert await hass.config_entries.async_unload(mock_config_entry.entry_id) - assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/guntamatic/test_sensor.py b/tests/components/guntamatic/test_sensor.py index b4fea8f9c0201..915cffb47737c 100644 --- a/tests/components/guntamatic/test_sensor.py +++ b/tests/components/guntamatic/test_sensor.py @@ -1,17 +1,23 @@ """Tests for the Guntamatic sensor platform.""" -from unittest.mock import patch +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.const import Platform +from homeassistant.components.guntamatic.const import SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntryState +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 .conftest import MOCK_PARSE_DATA -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.mark.usefixtures("mock_heater") @@ -33,3 +39,42 @@ async def test_all_entities( 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.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + + 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 + mock_heater.parse_data.return_value = MOCK_PARSE_DATA + 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 From 7b0360e4e7ba620dea0df95915533bba868befd6 Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:29:47 +0200 Subject: [PATCH 38/43] Removed unneeded code --- .../components/guntamatic/__init__.py | 6 +-- .../components/guntamatic/config_flow.py | 1 - .../components/guntamatic/coordinator.py | 7 ++- .../components/guntamatic/manifest.json | 3 -- homeassistant/components/guntamatic/sensor.py | 15 ++---- .../components/guntamatic/strings.json | 26 +++++----- homeassistant/generated/dhcp.py | 4 -- tests/components/guntamatic/conftest.py | 1 - .../guntamatic/snapshots/test_sensor.ambr | 50 ------------------- 9 files changed, 22 insertions(+), 91 deletions(-) diff --git a/homeassistant/components/guntamatic/__init__.py b/homeassistant/components/guntamatic/__init__.py index 948be23787c02..94f747bee1ad5 100644 --- a/homeassistant/components/guntamatic/__init__.py +++ b/homeassistant/components/guntamatic/__init__.py @@ -4,9 +4,7 @@ import logging -from guntamatic.heater import Heater - -from homeassistant.const import CONF_HOST, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .coordinator import GuntamaticConfigEntry, GuntamaticCoordinator @@ -17,7 +15,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GuntamaticConfigEntry) -> bool: """Set up guntamatic from a config entry.""" - coordinator = GuntamaticCoordinator(hass, Heater(entry.data[CONF_HOST]), entry) + coordinator = GuntamaticCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/guntamatic/config_flow.py b/homeassistant/components/guntamatic/config_flow.py index 2c5191f2dddfc..881d41e79258e 100644 --- a/homeassistant/components/guntamatic/config_flow.py +++ b/homeassistant/components/guntamatic/config_flow.py @@ -41,7 +41,6 @@ async def async_step_dhcp( except NoSerialException: return self.async_abort(reason="bad_data") - # 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(updates={CONF_HOST: discovery_info.ip}) self._discovered_ip = discovery_info.ip diff --git a/homeassistant/components/guntamatic/coordinator.py b/homeassistant/components/guntamatic/coordinator.py index 07f135622ce6b..8264e1b6bb794 100644 --- a/homeassistant/components/guntamatic/coordinator.py +++ b/homeassistant/components/guntamatic/coordinator.py @@ -8,6 +8,7 @@ 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 @@ -21,9 +22,7 @@ class GuntamaticCoordinator(DataUpdateCoordinator[dict[str, list[str]]]): """Guntamatic data coordinator.""" - def __init__( - self, hass: HomeAssistant, heater: Heater, entry: GuntamaticConfigEntry - ) -> None: + def __init__(self, hass: HomeAssistant, entry: GuntamaticConfigEntry) -> None: """Initialize coordinator.""" super().__init__( hass, @@ -32,7 +31,7 @@ def __init__( update_interval=SCAN_INTERVAL, config_entry=entry, ) - self.heater = heater + self.heater = Heater(entry.data[CONF_HOST]) async def _async_update_data(self) -> dict[str, list[str]]: """Fetch data from heater.""" diff --git a/homeassistant/components/guntamatic/manifest.json b/homeassistant/components/guntamatic/manifest.json index 58d3568862ffb..c865fe6c5c117 100644 --- a/homeassistant/components/guntamatic/manifest.json +++ b/homeassistant/components/guntamatic/manifest.json @@ -8,9 +8,6 @@ { "hostname": "kessel*", "macaddress": "0024BD*" - }, - { - "registered_devices": true } ], "documentation": "https://www.home-assistant.io/integrations/guntamatic", diff --git a/homeassistant/components/guntamatic/sensor.py b/homeassistant/components/guntamatic/sensor.py index d45c78c2fa0f7..2a64f88db7610 100644 --- a/homeassistant/components/guntamatic/sensor.py +++ b/homeassistant/components/guntamatic/sensor.py @@ -7,7 +7,7 @@ SensorStateClass, StateType, ) -from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature +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 @@ -19,11 +19,6 @@ PARALLEL_UPDATES = 0 GUNTAMATIC_SENSORS: list[SensorEntityDescription] = [ - SensorEntityDescription( - key="status", - translation_key="status", - entity_category=EntityCategory.DIAGNOSTIC, - ), SensorEntityDescription( key="program", translation_key="program", @@ -119,11 +114,9 @@ async def async_setup_entry( coordinator = entry.runtime_data async_add_entities( - [ - GuntamaticSensor(coordinator, description) - for description in GUNTAMATIC_SENSORS - if description.key in coordinator.data - ] + GuntamaticSensor(coordinator, description) + for description in GUNTAMATIC_SENSORS + if description.key in coordinator.data ) diff --git a/homeassistant/components/guntamatic/strings.json b/homeassistant/components/guntamatic/strings.json index a223445034c7a..1b5ec133baaa8 100644 --- a/homeassistant/components/guntamatic/strings.json +++ b/homeassistant/components/guntamatic/strings.json @@ -24,46 +24,46 @@ "entity": { "sensor": { "boiler_temperature": { - "name": "Boiler Temperature" + "name": "Boiler temperature" }, "buffer_bottom_temperature": { - "name": "Buffer Bottem Temperature" + "name": "Buffer bottem temperature" }, "buffer_center_temperature": { - "name": "Buffer Center Temperature" + "name": "Buffer center temperature" }, "buffer_load": { - "name": "Buffer Load" + "name": "Buffer load" }, "buffer_top_temperature": { - "name": "Buffer Top Temperature" + "name": "Buffer top temperature" }, "domestic_home_water_temperature": { - "name": "Domestic Home Water Temperature" + "name": "Domestic home water temperature" }, "outdoor_temperature": { - "name": "Outdoor Temperature" + "name": "Outdoor temperature" }, "program": { "name": "Program", "state": { - "dhw": "Domestic Hot Water", - "dhw_boost": "Domestic Hot Water Boost", + "dhw": "Domestic hot water", + "dhw_boost": "Domestic hot water boost", "heat": "Heat", "hibernate": "Hibernate", - "hibernate_to": "Hibernate To", + "hibernate_to": "Hibernate to", "off": "[%key:common::state::off%]", "timer": "Timer" } }, "room_0_temperature": { - "name": "Room 0 Temperature" + "name": "Room 0 temperature" }, "room_1_temperature": { - "name": "Room 1 Temperature" + "name": "Room 1 temperature" }, "room_2_temperature": { - "name": "Room 2 Temperature" + "name": "Room 2 temperature" }, "status": { "name": "Status" diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 7590471a94bfb..1e68e1af9d0ef 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -302,10 +302,6 @@ "hostname": "kessel*", "macaddress": "0024BD*", }, - { - "domain": "guntamatic", - "registered_devices": True, - }, { "domain": "home_connect", "hostname": "balay-*", diff --git a/tests/components/guntamatic/conftest.py b/tests/components/guntamatic/conftest.py index 214d38c206ee6..560e8320c2e28 100644 --- a/tests/components/guntamatic/conftest.py +++ b/tests/components/guntamatic/conftest.py @@ -39,7 +39,6 @@ def mock_heater() -> Generator[MagicMock]: "guntamatic.heater.Heater", autospec=True, ) as mock, - patch("homeassistant.components.guntamatic.Heater", new=mock), patch("homeassistant.components.guntamatic.coordinator.Heater", new=mock), patch("homeassistant.components.guntamatic.config_flow.Heater", new=mock), ): diff --git a/tests/components/guntamatic/snapshots/test_sensor.ambr b/tests/components/guntamatic/snapshots/test_sensor.ambr index 1fc7454c07790..0e3209278af5a 100644 --- a/tests/components/guntamatic/snapshots/test_sensor.ambr +++ b/tests/components/guntamatic/snapshots/test_sensor.ambr @@ -239,53 +239,3 @@ 'state': 'heat', }) # --- -# name: test_all_entities[sensor.mock_title_status-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.mock_title_status', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Status', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Status', - 'platform': 'guntamatic', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'status', - 'unique_id': '959103_status', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.mock_title_status-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Title Status', - }), - 'context': , - 'entity_id': 'sensor.mock_title_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'Service Ign.', - }) -# --- From d68f468ed641e70965793953b6f167b1b9cba6d5 Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:55:51 +0200 Subject: [PATCH 39/43] Fix mockconfigentry type, added translations --- homeassistant/components/guntamatic/strings.json | 4 ++++ tests/components/guntamatic/__init__.py | 2 +- tests/components/guntamatic/test_config_flow.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/guntamatic/strings.json b/homeassistant/components/guntamatic/strings.json index 1b5ec133baaa8..4b4694b9c40ca 100644 --- a/homeassistant/components/guntamatic/strings.json +++ b/homeassistant/components/guntamatic/strings.json @@ -11,6 +11,10 @@ "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%]" diff --git a/tests/components/guntamatic/__init__.py b/tests/components/guntamatic/__init__.py index d4bb45c713fdb..0d368331d9197 100644 --- a/tests/components/guntamatic/__init__.py +++ b/tests/components/guntamatic/__init__.py @@ -8,7 +8,7 @@ async def setup_integration( hass: HomeAssistant, config_entry: MockConfigEntry, -) -> None: +) -> MockConfigEntry: """Set up the Guntamatic integration for testing.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/guntamatic/test_config_flow.py b/tests/components/guntamatic/test_config_flow.py index 626871da061ba..f819092808acd 100644 --- a/tests/components/guntamatic/test_config_flow.py +++ b/tests/components/guntamatic/test_config_flow.py @@ -87,7 +87,7 @@ async def test_form_already_configured( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_heater: MagicMock, - mock_config_entry: MagicMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test we abort if already configured.""" mock_config_entry.add_to_hass(hass) From b364784b7252346927d54bbf26d56c5eff2a92c6 Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:42:45 +0200 Subject: [PATCH 40/43] Fixed sentence case in ambr file --- tests/components/guntamatic/conftest.py | 6 +++--- .../guntamatic/snapshots/test_sensor.ambr | 18 +++++++++--------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/components/guntamatic/conftest.py b/tests/components/guntamatic/conftest.py index 560e8320c2e28..38d50e82cf665 100644 --- a/tests/components/guntamatic/conftest.py +++ b/tests/components/guntamatic/conftest.py @@ -11,9 +11,9 @@ from tests.common import MockConfigEntry MOCK_DATA = { - "Boiler Temperature": ["14.09", "°C"], - "Outdoor Temperature": ["15.95", "°C"], - "Buffer Load": ["22", "%"], + "Boiler temperature": ["14.09", "°C"], + "Outdoor temperature": ["15.95", "°C"], + "Buffer load": ["22", "%"], "Program": ["HEAT", ""], "Status": ["Service Ign.", ""], "Serial": ["959103", ""], diff --git a/tests/components/guntamatic/snapshots/test_sensor.ambr b/tests/components/guntamatic/snapshots/test_sensor.ambr index 0e3209278af5a..d42a6cdf48cf6 100644 --- a/tests/components/guntamatic/snapshots/test_sensor.ambr +++ b/tests/components/guntamatic/snapshots/test_sensor.ambr @@ -23,7 +23,7 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Boiler Temperature', + 'object_id_base': 'Boiler temperature', 'options': dict({ 'sensor': dict({ 'suggested_display_precision': 1, @@ -31,7 +31,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Boiler Temperature', + 'original_name': 'Boiler temperature', 'platform': 'guntamatic', 'previous_unique_id': None, 'suggested_object_id': None, @@ -45,7 +45,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Mock Title Boiler Temperature', + 'friendly_name': 'Mock Title Boiler temperature', 'state_class': , 'unit_of_measurement': , }), @@ -81,12 +81,12 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Buffer Load', + 'object_id_base': 'Buffer load', 'options': dict({ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Buffer Load', + 'original_name': 'Buffer load', 'platform': 'guntamatic', 'previous_unique_id': None, 'suggested_object_id': None, @@ -99,7 +99,7 @@ # name: test_all_entities[sensor.mock_title_buffer_load-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Title Buffer Load', + 'friendly_name': 'Mock Title Buffer load', 'state_class': , 'unit_of_measurement': '%', }), @@ -135,7 +135,7 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Outdoor Temperature', + 'object_id_base': 'Outdoor temperature', 'options': dict({ 'sensor': dict({ 'suggested_display_precision': 1, @@ -143,7 +143,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Outdoor Temperature', + 'original_name': 'Outdoor temperature', 'platform': 'guntamatic', 'previous_unique_id': None, 'suggested_object_id': None, @@ -157,7 +157,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Mock Title Outdoor Temperature', + 'friendly_name': 'Mock Title Outdoor temperature', 'state_class': , 'unit_of_measurement': , }), From 5e4752782761966a96ec4bb6b398333890fb51a5 Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:53:20 +0200 Subject: [PATCH 41/43] More asserts on unique_id --- tests/components/guntamatic/test_config_flow.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/components/guntamatic/test_config_flow.py b/tests/components/guntamatic/test_config_flow.py index f819092808acd..fa9247777f1a6 100644 --- a/tests/components/guntamatic/test_config_flow.py +++ b/tests/components/guntamatic/test_config_flow.py @@ -180,6 +180,7 @@ async def test_dhcp_discovery_confirm( 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] async def test_dhcp_updates_ip( @@ -207,3 +208,4 @@ async def test_dhcp_updates_ip( 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] From 2f7b88f9d82998d78b77180121c7a6336f1ef8c6 Mon Sep 17 00:00:00 2001 From: Jens Timmerman <281523+JensTimmerman@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:29:24 +0200 Subject: [PATCH 42/43] fix typo --- homeassistant/components/guntamatic/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/guntamatic/strings.json b/homeassistant/components/guntamatic/strings.json index 4b4694b9c40ca..82499cd1946a4 100644 --- a/homeassistant/components/guntamatic/strings.json +++ b/homeassistant/components/guntamatic/strings.json @@ -31,7 +31,7 @@ "name": "Boiler temperature" }, "buffer_bottom_temperature": { - "name": "Buffer bottem temperature" + "name": "Buffer bottom temperature" }, "buffer_center_temperature": { "name": "Buffer center temperature" From 940b44ef934b4590178b8574c5b0a45d1529d67a Mon Sep 17 00:00:00 2001 From: Joostlek Date: Tue, 28 Apr 2026 20:42:41 +0200 Subject: [PATCH 43/43] Fix --- tests/components/guntamatic/__init__.py | 3 +- tests/components/guntamatic/conftest.py | 3 +- .../components/guntamatic/test_config_flow.py | 57 +++++-------------- .../components/guntamatic/test_coordinator.py | 51 ----------------- tests/components/guntamatic/test_sensor.py | 21 +++---- 5 files changed, 23 insertions(+), 112 deletions(-) delete mode 100644 tests/components/guntamatic/test_coordinator.py diff --git a/tests/components/guntamatic/__init__.py b/tests/components/guntamatic/__init__.py index 0d368331d9197..e91bc419c1828 100644 --- a/tests/components/guntamatic/__init__.py +++ b/tests/components/guntamatic/__init__.py @@ -8,9 +8,8 @@ async def setup_integration( hass: HomeAssistant, config_entry: MockConfigEntry, -) -> 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() - return config_entry diff --git a/tests/components/guntamatic/conftest.py b/tests/components/guntamatic/conftest.py index 38d50e82cf665..53d36e1c5b9ba 100644 --- a/tests/components/guntamatic/conftest.py +++ b/tests/components/guntamatic/conftest.py @@ -36,10 +36,9 @@ def mock_heater() -> Generator[MagicMock]: """Mock the Heater class.""" with ( patch( - "guntamatic.heater.Heater", + "homeassistant.components.guntamatic.coordinator.Heater", autospec=True, ) as mock, - patch("homeassistant.components.guntamatic.coordinator.Heater", new=mock), patch("homeassistant.components.guntamatic.config_flow.Heater", new=mock), ): instance = mock.return_value diff --git a/tests/components/guntamatic/test_config_flow.py b/tests/components/guntamatic/test_config_flow.py index fa9247777f1a6..39b9a43630712 100644 --- a/tests/components/guntamatic/test_config_flow.py +++ b/tests/components/guntamatic/test_config_flow.py @@ -6,9 +6,8 @@ import pytest import requests -from homeassistant import config_entries from homeassistant.components.guntamatic.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER, ConfigEntryState +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 @@ -32,8 +31,7 @@ async def test_form( assert result["errors"] == {} result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: "1.1.1.1"}, + result["flow_id"], {CONF_HOST: "1.1.1.1"} ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -76,8 +74,7 @@ async def test_form_errors( 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"}, + result["flow_id"], {CONF_HOST: "1.1.1.1"} ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -96,8 +93,7 @@ async def test_form_already_configured( DOMAIN, context={"source": SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: "1.1.1.1"}, + result["flow_id"], {CONF_HOST: "1.1.1.1"} ) assert result["type"] is FlowResultType.ABORT @@ -112,18 +108,23 @@ async def test_dhcp_discovery( """Test DHCP discovery shows confirmation form.""" result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, + context={"source": SOURCE_DHCP}, data=DhcpServiceInfo( ip="1.1.1.1", hostname="kessel0001", macaddress="0024bd123456", ), ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM assert result["step_id"] == "discovery_confirm" + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + 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"), @@ -143,46 +144,18 @@ async def test_dhcp_discovery_errors( mock_heater.parse_data.side_effect = (side_effect,) result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, + context={"source": SOURCE_DHCP}, data=DhcpServiceInfo( ip="1.1.1.1", hostname="kessel0001", macaddress="0024bd123456", ), ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == expected_error -async def test_dhcp_discovery_confirm( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - mock_heater: MagicMock, -) -> None: - """Test DHCP discovery confirmation creates entry.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=DhcpServiceInfo( - ip="1.1.1.1", - hostname="kessel0001", - macaddress="0024bd123456", - ), - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: "1.1.1.1"}, - ) - await hass.async_block_till_done() - - 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] - - async def test_dhcp_updates_ip( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -190,20 +163,16 @@ async def test_dhcp_updates_ip( ) -> None: """Test DHCP discovery updates IP when device changes address.""" mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - assert mock_config_entry.state is ConfigEntryState.LOADED result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, + context={"source": SOURCE_DHCP}, data=DhcpServiceInfo( ip="1.1.1.2", hostname="kessel0001", macaddress="0024bd123456", ), ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" diff --git a/tests/components/guntamatic/test_coordinator.py b/tests/components/guntamatic/test_coordinator.py deleted file mode 100644 index 615fe49830da8..0000000000000 --- a/tests/components/guntamatic/test_coordinator.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Test the coordinator.""" - -from unittest.mock import MagicMock - -from freezegun.api import FrozenDateTimeFactory -import requests - -from homeassistant.components.guntamatic.const import SCAN_INTERVAL -from homeassistant.core import HomeAssistant - -from .conftest import MOCK_PARSE_DATA - -from tests.common import MockConfigEntry, async_fire_time_changed - - -async def test_coordinator_update_failed( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_heater: MagicMock, - freezer: FrozenDateTimeFactory, -) -> None: - """Test coordinator raises UpdateFailed on connection error.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - mock_heater.parse_data.side_effect = ( - requests.exceptions.ConnectionError("Connection lost"), - ) - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() - assert mock_config_entry.runtime_data.last_update_success is False - - -async def test_coordinator_returns_data( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_heater: MagicMock, - freezer: FrozenDateTimeFactory, -) -> None: - """Test coordinator returns correct data.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() - assert mock_config_entry.runtime_data.last_update_success is True - assert mock_config_entry.runtime_data.data == MOCK_PARSE_DATA diff --git a/tests/components/guntamatic/test_sensor.py b/tests/components/guntamatic/test_sensor.py index 915cffb47737c..2876f2dfc0dd2 100644 --- a/tests/components/guntamatic/test_sensor.py +++ b/tests/components/guntamatic/test_sensor.py @@ -9,13 +9,11 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.guntamatic.const import SCAN_INTERVAL -from homeassistant.config_entries import ConfigEntryState 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 .conftest import MOCK_PARSE_DATA from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -33,12 +31,12 @@ async def test_all_entities( [Platform.SENSOR], ): await setup_integration(hass, mock_config_entry) - await snapshot_platform( - hass, - entity_registry, - snapshot, - mock_config_entry.entry_id, - ) + await snapshot_platform( + hass, + entity_registry, + snapshot, + mock_config_entry.entry_id, + ) @pytest.mark.parametrize( @@ -57,10 +55,7 @@ async def test_state_unavailable( freezer: FrozenDateTimeFactory, ) -> None: """Test sensors handle failures.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - assert mock_config_entry.state is ConfigEntryState.LOADED + await setup_integration(hass, mock_config_entry) mock_heater.parse_data.side_effect = side_effect freezer.tick(SCAN_INTERVAL) @@ -72,7 +67,7 @@ async def test_state_unavailable( # Recovery mock_heater.parse_data.side_effect = None - mock_heater.parse_data.return_value = MOCK_PARSE_DATA + freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done()