diff --git a/.strict-typing b/.strict-typing index 43ddeb282dd7f..0ca1d7338b98c 100644 --- a/.strict-typing +++ b/.strict-typing @@ -248,6 +248,7 @@ homeassistant.components.gpsd.* homeassistant.components.greeneye_monitor.* homeassistant.components.group.* homeassistant.components.guardian.* +homeassistant.components.guntamatic.* homeassistant.components.habitica.* homeassistant.components.hardkernel.* homeassistant.components.hardware.* diff --git a/CODEOWNERS b/CODEOWNERS index 4a852cf07c46e..322114148118e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -688,6 +688,8 @@ CLAUDE.md @home-assistant/core /tests/components/growatt_server/ @johanzander /homeassistant/components/guardian/ @bachya /tests/components/guardian/ @bachya +/homeassistant/components/guntamatic/ @JensTimmerman +/tests/components/guntamatic/ @JensTimmerman /homeassistant/components/habitica/ @tr4nt0r /tests/components/habitica/ @tr4nt0r /homeassistant/components/hanna/ @bestycame diff --git a/homeassistant/components/guntamatic/__init__.py b/homeassistant/components/guntamatic/__init__.py new file mode 100644 index 0000000000000..94f747bee1ad5 --- /dev/null +++ b/homeassistant/components/guntamatic/__init__.py @@ -0,0 +1,28 @@ +"""The guntamatic integration.""" + +from __future__ import annotations + +import logging + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import GuntamaticConfigEntry, GuntamaticCoordinator + +_LOGGER = logging.getLogger(__name__) +_PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: GuntamaticConfigEntry) -> bool: + """Set up guntamatic from a config entry.""" + coordinator = GuntamaticCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: GuntamaticConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/guntamatic/config_flow.py b/homeassistant/components/guntamatic/config_flow.py new file mode 100644 index 0000000000000..881d41e79258e --- /dev/null +++ b/homeassistant/components/guntamatic/config_flow.py @@ -0,0 +1,92 @@ +"""Config flow for the guntamatic integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from guntamatic.heater import Heater, NoSerialException +import requests +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + } +) + + +class GuntamaticConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for guntamatic.""" + + _discovered_ip: str + + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery.""" + heater = Heater(discovery_info.ip) + try: + data = await self.hass.async_add_executor_job(heater.parse_data) + except requests.exceptions.RequestException: + return self.async_abort(reason="cannot_connect") + except NoSerialException: + return self.async_abort(reason="bad_data") + + await self.async_set_unique_id(data["serial"][0]) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) + self._discovered_ip = discovery_info.ip + + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + if user_input is not None: + return self.async_create_entry( + title="Guntamatic Heater", + data={CONF_HOST: self._discovered_ip}, + ) + + self._set_confirm_only() + return self.async_show_form(step_id="discovery_confirm") + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + try: + heater = Heater(user_input[CONF_HOST]) + data = await self.hass.async_add_executor_job(heater.parse_data) + except requests.exceptions.RequestException: + errors["base"] = "cannot_connect" + except NoSerialException: + errors["base"] = "bad_data" + + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + # set serial as unique id for deduplication, ip isn't a good match + await self.async_set_unique_id(data["serial"][0]) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title="Guntamatic Heater", data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/guntamatic/const.py b/homeassistant/components/guntamatic/const.py new file mode 100644 index 0000000000000..022fc4a2dd3d8 --- /dev/null +++ b/homeassistant/components/guntamatic/const.py @@ -0,0 +1,6 @@ +"""Constants for the guntamatic integration.""" + +from datetime import timedelta + +DOMAIN = "guntamatic" +SCAN_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/guntamatic/coordinator.py b/homeassistant/components/guntamatic/coordinator.py new file mode 100644 index 0000000000000..8264e1b6bb794 --- /dev/null +++ b/homeassistant/components/guntamatic/coordinator.py @@ -0,0 +1,44 @@ +"""Coordinator for Guntamatic integration.""" + +from __future__ import annotations + +import logging + +from guntamatic.heater import Heater +import requests + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + +type GuntamaticConfigEntry = ConfigEntry[GuntamaticCoordinator] + + +class GuntamaticCoordinator(DataUpdateCoordinator[dict[str, list[str]]]): + """Guntamatic data coordinator.""" + + def __init__(self, hass: HomeAssistant, entry: GuntamaticConfigEntry) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + logger=_LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + config_entry=entry, + ) + self.heater = Heater(entry.data[CONF_HOST]) + + async def _async_update_data(self) -> dict[str, list[str]]: + """Fetch data from heater.""" + try: + data: dict[str, list[str]] = await self.hass.async_add_executor_job( + self.heater.parse_data + ) + except requests.exceptions.ConnectionError as err: + raise UpdateFailed(f"Cannot connect to heater: {err}") from err + return data diff --git a/homeassistant/components/guntamatic/manifest.json b/homeassistant/components/guntamatic/manifest.json new file mode 100644 index 0000000000000..c865fe6c5c117 --- /dev/null +++ b/homeassistant/components/guntamatic/manifest.json @@ -0,0 +1,18 @@ +{ + "domain": "guntamatic", + "name": "Guntamatic", + "codeowners": ["@JensTimmerman"], + "config_flow": true, + "dependencies": [], + "dhcp": [ + { + "hostname": "kessel*", + "macaddress": "0024BD*" + } + ], + "documentation": "https://www.home-assistant.io/integrations/guntamatic", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "silver", + "requirements": ["guntamatic==1.5.0"] +} diff --git a/homeassistant/components/guntamatic/quality_scale.yaml b/homeassistant/components/guntamatic/quality_scale.yaml new file mode 100644 index 0000000000000..bc26329ff7944 --- /dev/null +++ b/homeassistant/components/guntamatic/quality_scale.yaml @@ -0,0 +1,72 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No custom actions are defined. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + No custom actions are defined. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options flow + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: No authentication required. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: done + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: Single device + entity-category: done + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/guntamatic/sensor.py b/homeassistant/components/guntamatic/sensor.py new file mode 100644 index 0000000000000..2a64f88db7610 --- /dev/null +++ b/homeassistant/components/guntamatic/sensor.py @@ -0,0 +1,151 @@ +"""Support for Guntamatic sensors in Home Assistant.""" + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, + StateType, +) +from homeassistant.const import PERCENTAGE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import GuntamaticConfigEntry, GuntamaticCoordinator + +PARALLEL_UPDATES = 0 + +GUNTAMATIC_SENSORS: list[SensorEntityDescription] = [ + SensorEntityDescription( + key="program", + translation_key="program", + device_class=SensorDeviceClass.ENUM, + options=[ + "off", + "timer", + "dhw", + "heat", + "hibernate", + "hibernate_to", + "dhw_boost", + ], + ), + SensorEntityDescription( + key="boiler_temperature", + translation_key="boiler_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + SensorEntityDescription( + key="outdoor_temperature", + translation_key="outdoor_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + SensorEntityDescription( + key="buffer_top_temperature", + translation_key="buffer_top_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + SensorEntityDescription( + key="buffer_center_temperature", + translation_key="buffer_center_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + SensorEntityDescription( + key="buffer_bottom_temperature", + translation_key="buffer_bottom_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + SensorEntityDescription( + key="domestic_home_water_temperature", + translation_key="domestic_home_water_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + SensorEntityDescription( + key="room_0_temperature", + translation_key="room_0_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + SensorEntityDescription( + key="room_1_temperature", + translation_key="room_1_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + SensorEntityDescription( + key="room_2_temperature", + translation_key="room_2_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + SensorEntityDescription( + key="buffer_load", + translation_key="buffer_load", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: GuntamaticConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Guntamatic sensors from config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + GuntamaticSensor(coordinator, description) + for description in GUNTAMATIC_SENSORS + if description.key in coordinator.data + ) + + +class GuntamaticSensor(CoordinatorEntity[GuntamaticCoordinator], SensorEntity): + """Representation of a single Guntamatic sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: GuntamaticCoordinator, + entity_description: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = entity_description + + serial = coordinator.data["serial"][0] + + self._attr_unique_id = f"{serial.replace('.', '_')}_{entity_description.key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, serial)}, + manufacturer="Guntamatic", + serial_number=serial, + sw_version=coordinator.data["version"][0], + ) + + @property + def native_value(self) -> StateType: + """Return the current value of the sensor.""" + return self.coordinator.data[self.entity_description.key][0] diff --git a/homeassistant/components/guntamatic/strings.json b/homeassistant/components/guntamatic/strings.json new file mode 100644 index 0000000000000..82499cd1946a4 --- /dev/null +++ b/homeassistant/components/guntamatic/strings.json @@ -0,0 +1,77 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "bad_data": "The heater did not provide the required identification information. Verify that the host points to a supported controller and the correct endpoint.", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "error": { + "bad_data": "The heater did not provide the required identification information. Verify that the host points to a supported controller and the correct endpoint.", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "discovery_confirm": { + "description": "Do you want to set up this Guntamatic heater?", + "title": "Discovered Guntamatic heater" + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Guntamatic heater." + } + } + } + }, + "entity": { + "sensor": { + "boiler_temperature": { + "name": "Boiler temperature" + }, + "buffer_bottom_temperature": { + "name": "Buffer bottom temperature" + }, + "buffer_center_temperature": { + "name": "Buffer center temperature" + }, + "buffer_load": { + "name": "Buffer load" + }, + "buffer_top_temperature": { + "name": "Buffer top temperature" + }, + "domestic_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/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1a5237e768bc9..38ee7bf8a7c40 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -288,6 +288,7 @@ "green_planet_energy", "growatt_server", "guardian", + "guntamatic", "habitica", "hanna", "harmony", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 700374cf8dac7..1eb3efb757683 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -305,6 +305,11 @@ "hostname": "guardian*", "macaddress": "30AEA4*", }, + { + "domain": "guntamatic", + "hostname": "kessel*", + "macaddress": "0024BD*", + }, { "domain": "home_connect", "hostname": "balay-*", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 5993f39123e16..850000efd8886 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2713,6 +2713,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "guntamatic": { + "name": "Guntamatic", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "habitica": { "name": "Habitica", "integration_type": "service", diff --git a/mypy.ini b/mypy.ini index 9f64f3e56506a..83ec47a61740b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2235,6 +2235,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.guntamatic.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.habitica.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 33aae778be2a9..5a874e1a8d30b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1170,6 +1170,9 @@ growattServer==1.9.0 # homeassistant.components.google_sheets gspread==5.5.0 +# homeassistant.components.guntamatic +guntamatic==1.5.0 + # homeassistant.components.profiler guppy3==3.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 99d2c949cd924..3c06ac4d70408 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1043,6 +1043,9 @@ growattServer==1.9.0 # homeassistant.components.google_sheets gspread==5.5.0 +# homeassistant.components.guntamatic +guntamatic==1.5.0 + # homeassistant.components.profiler guppy3==3.1.6 diff --git a/tests/components/guntamatic/__init__.py b/tests/components/guntamatic/__init__.py new file mode 100644 index 0000000000000..e91bc419c1828 --- /dev/null +++ b/tests/components/guntamatic/__init__.py @@ -0,0 +1,15 @@ +"""Tests for the Guntamatic integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Set up the Guntamatic integration for testing.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/guntamatic/conftest.py b/tests/components/guntamatic/conftest.py new file mode 100644 index 0000000000000..53d36e1c5b9ba --- /dev/null +++ b/tests/components/guntamatic/conftest.py @@ -0,0 +1,67 @@ +"""Common fixtures for the guntamatic tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.guntamatic.const import DOMAIN +from homeassistant.const import CONF_HOST + +from tests.common import MockConfigEntry + +MOCK_DATA = { + "Boiler temperature": ["14.09", "°C"], + "Outdoor temperature": ["15.95", "°C"], + "Buffer load": ["22", "%"], + "Program": ["HEAT", ""], + "Status": ["Service Ign.", ""], + "Serial": ["959103", ""], + "Version": ["32a", ""], +} + +MOCK_PARSE_DATA = { + "boiler_temperature": ["14.09", "°C"], + "outdoor_temperature": ["15.95", "°C"], + "buffer_load": ["22", "%"], + "program": ["heat", ""], + "status": ["Service Ign.", ""], + "serial": ["959103", ""], + "version": ["32a", ""], +} + + +@pytest.fixture +def mock_heater() -> Generator[MagicMock]: + """Mock the Heater class.""" + with ( + patch( + "homeassistant.components.guntamatic.coordinator.Heater", + autospec=True, + ) as mock, + patch("homeassistant.components.guntamatic.config_flow.Heater", new=mock), + ): + instance = mock.return_value + instance.parse_data.return_value = MOCK_PARSE_DATA + instance.host = "1.1.1.1" + yield instance + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "1.1.1.1"}, + unique_id=MOCK_DATA["Serial"][0], + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.guntamatic.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/guntamatic/snapshots/test_sensor.ambr b/tests/components/guntamatic/snapshots/test_sensor.ambr new file mode 100644 index 0000000000000..d42a6cdf48cf6 --- /dev/null +++ b/tests/components/guntamatic/snapshots/test_sensor.ambr @@ -0,0 +1,241 @@ +# serializer version: 1 +# name: test_all_entities[sensor.mock_title_boiler_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_boiler_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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': 'boiler_temperature', + 'unique_id': '959103_boiler_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.mock_title_boiler_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Mock Title Boiler temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_boiler_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.09', + }) +# --- +# name: test_all_entities[sensor.mock_title_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.mock_title_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': 'buffer_load', + 'unique_id': '959103_buffer_load', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.mock_title_buffer_load-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Buffer load', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.mock_title_buffer_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22', + }) +# --- +# name: test_all_entities[sensor.mock_title_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.mock_title_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': 'outdoor_temperature', + 'unique_id': '959103_outdoor_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.mock_title_outdoor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Mock Title Outdoor temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_outdoor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.95', + }) +# --- +# name: test_all_entities[sensor.mock_title_program-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'timer', + 'dhw', + 'heat', + 'hibernate', + 'hibernate_to', + 'dhw_boost', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_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': 'program', + 'unique_id': '959103_program', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.mock_title_program-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock Title Program', + 'options': list([ + 'off', + 'timer', + 'dhw', + 'heat', + 'hibernate', + 'hibernate_to', + 'dhw_boost', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_title_program', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/guntamatic/test_config_flow.py b/tests/components/guntamatic/test_config_flow.py new file mode 100644 index 0000000000000..39b9a43630712 --- /dev/null +++ b/tests/components/guntamatic/test_config_flow.py @@ -0,0 +1,180 @@ +"""Test the guntamatic config flow.""" + +from unittest.mock import AsyncMock, MagicMock + +from guntamatic.heater import NoSerialException +import pytest +import requests + +from homeassistant.components.guntamatic.const import DOMAIN +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo + +from .conftest import MOCK_DATA, MOCK_PARSE_DATA + +from tests.common import MockConfigEntry + + +async def test_form( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_heater: MagicMock, +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "1.1.1.1"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Guntamatic Heater" + assert result["data"] == {CONF_HOST: "1.1.1.1"} + + assert result["result"].unique_id == MOCK_DATA["Serial"][0] + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (requests.exceptions.ConnectionError, "cannot_connect"), + (NoSerialException, "bad_data"), + (Exception("Unknown error"), "unknown"), + ], +) +async def test_form_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + side_effect: Exception, + expected_error: str, + mock_heater: MagicMock, +) -> None: + """Test we handle errors correctly.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + mock_heater.parse_data.side_effect = (side_effect,) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + # Recover from error + mock_heater.parse_data.side_effect = None + mock_heater.parse_data.return_value = MOCK_PARSE_DATA + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "1.1.1.1"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_form_already_configured( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_heater: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we abort if already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: "1.1.1.1"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_dhcp_discovery( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_heater: MagicMock, +) -> None: + """Test DHCP discovery shows confirmation form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="1.1.1.1", + hostname="kessel0001", + macaddress="0024bd123456", + ), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_HOST: "1.1.1.1"} + assert result["result"].unique_id == MOCK_DATA["Serial"][0] + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (requests.exceptions.ConnectionError, "cannot_connect"), + (NoSerialException, "bad_data"), + ], +) +async def test_dhcp_discovery_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + side_effect: Exception, + expected_error: str, + mock_heater: MagicMock, +) -> None: + """Test DHCP discovery shows confirmation form.""" + mock_heater.parse_data.side_effect = (side_effect,) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="1.1.1.1", + hostname="kessel0001", + macaddress="0024bd123456", + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_error + + +async def test_dhcp_updates_ip( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_heater: MagicMock, +) -> None: + """Test DHCP discovery updates IP when device changes address.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="1.1.1.2", + hostname="kessel0001", + macaddress="0024bd123456", + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_HOST] == "1.1.1.2" + assert mock_config_entry.unique_id == MOCK_DATA["Serial"][0] diff --git a/tests/components/guntamatic/test_init.py b/tests/components/guntamatic/test_init.py new file mode 100644 index 0000000000000..93e2fbc165010 --- /dev/null +++ b/tests/components/guntamatic/test_init.py @@ -0,0 +1,47 @@ +"""Test the Guntamatic integration setup.""" + +from unittest.mock import MagicMock + +import pytest +import requests + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_setup_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_heater: MagicMock, +) -> None: + """Test successful setup of the integration.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("side_effect", "expected_state"), + [ + ( + requests.exceptions.ConnectionError("Cannot connect"), + ConfigEntryState.SETUP_RETRY, + ), + ], +) +async def test_setup_entry_fails( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_heater: MagicMock, + side_effect: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test setup fails correctly for different error types.""" + mock_heater.parse_data.side_effect = side_effect + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is expected_state diff --git a/tests/components/guntamatic/test_sensor.py b/tests/components/guntamatic/test_sensor.py new file mode 100644 index 0000000000000..2876f2dfc0dd2 --- /dev/null +++ b/tests/components/guntamatic/test_sensor.py @@ -0,0 +1,75 @@ +"""Tests for the Guntamatic sensor platform.""" + +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +from guntamatic.heater import NoSerialException +import pytest +import requests +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.guntamatic.const import SCAN_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("mock_heater") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.guntamatic._PLATFORMS", + [Platform.SENSOR], + ): + await setup_integration(hass, mock_config_entry) + await snapshot_platform( + hass, + entity_registry, + snapshot, + mock_config_entry.entry_id, + ) + + +@pytest.mark.parametrize( + "side_effect", + [ + requests.exceptions.ConnectionError("Connection lost"), + NoSerialException, + Exception("Unknown error"), + ], +) +async def test_state_unavailable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_heater: MagicMock, + side_effect: Exception, + freezer: FrozenDateTimeFactory, +) -> None: + """Test sensors handle failures.""" + await setup_integration(hass, mock_config_entry) + + mock_heater.parse_data.side_effect = side_effect + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.mock_title_boiler_temperature") + assert state.state == STATE_UNAVAILABLE + + # Recovery + mock_heater.parse_data.side_effect = None + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("sensor.mock_title_boiler_temperature") + assert state.state != STATE_UNAVAILABLE