From 1e99b8786840862154939e7d54aead24be6e3b66 Mon Sep 17 00:00:00 2001 From: jukrebs <76174575+MaestroOnICe@users.noreply.github.com> Date: Tue, 4 Feb 2025 23:36:47 +0100 Subject: [PATCH] Add iometer integration (#135513) --- CODEOWNERS | 2 + homeassistant/components/iometer/__init__.py | 39 ++++ .../components/iometer/config_flow.py | 91 ++++++++++ homeassistant/components/iometer/const.py | 5 + .../components/iometer/coordinator.py | 55 ++++++ homeassistant/components/iometer/entity.py | 24 +++ homeassistant/components/iometer/icons.json | 38 ++++ .../components/iometer/manifest.json | 12 ++ .../components/iometer/quality_scale.yaml | 74 ++++++++ homeassistant/components/iometer/sensor.py | 146 +++++++++++++++ homeassistant/components/iometer/strings.json | 65 +++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + homeassistant/generated/zeroconf.py | 5 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/iometer/__init__.py | 1 + tests/components/iometer/conftest.py | 57 ++++++ .../components/iometer/fixtures/reading.json | 14 ++ tests/components/iometer/fixtures/status.json | 19 ++ tests/components/iometer/test_config_flow.py | 171 ++++++++++++++++++ 21 files changed, 831 insertions(+) create mode 100644 homeassistant/components/iometer/__init__.py create mode 100644 homeassistant/components/iometer/config_flow.py create mode 100644 homeassistant/components/iometer/const.py create mode 100644 homeassistant/components/iometer/coordinator.py create mode 100644 homeassistant/components/iometer/entity.py create mode 100644 homeassistant/components/iometer/icons.json create mode 100644 homeassistant/components/iometer/manifest.json create mode 100644 homeassistant/components/iometer/quality_scale.yaml create mode 100644 homeassistant/components/iometer/sensor.py create mode 100644 homeassistant/components/iometer/strings.json create mode 100644 tests/components/iometer/__init__.py create mode 100644 tests/components/iometer/conftest.py create mode 100644 tests/components/iometer/fixtures/reading.json create mode 100644 tests/components/iometer/fixtures/status.json create mode 100644 tests/components/iometer/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index 75dd38a5ac7..e510eec6dfa 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -731,6 +731,8 @@ build.json @home-assistant/supervisor /homeassistant/components/intent/ @home-assistant/core @synesthesiam /tests/components/intent/ @home-assistant/core @synesthesiam /homeassistant/components/intesishome/ @jnimmo +/homeassistant/components/iometer/ @MaestroOnICe +/tests/components/iometer/ @MaestroOnICe /homeassistant/components/ios/ @robbiet480 /tests/components/ios/ @robbiet480 /homeassistant/components/iotawatt/ @gtdiehl @jyavenard diff --git a/homeassistant/components/iometer/__init__.py b/homeassistant/components/iometer/__init__.py new file mode 100644 index 00000000000..5106d449fed --- /dev/null +++ b/homeassistant/components/iometer/__init__.py @@ -0,0 +1,39 @@ +"""The IOmeter integration.""" + +from __future__ import annotations + +from iometer import IOmeterClient, IOmeterConnectionError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import IOmeterConfigEntry, IOMeterCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: IOmeterConfigEntry) -> bool: + """Set up IOmeter from a config entry.""" + + host = entry.data[CONF_HOST] + session = async_get_clientsession(hass) + client = IOmeterClient(host=host, session=session) + try: + await client.get_current_status() + except IOmeterConnectionError as err: + raise ConfigEntryNotReady from err + + coordinator = IOMeterCoordinator(hass, client) + 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: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/iometer/config_flow.py b/homeassistant/components/iometer/config_flow.py new file mode 100644 index 00000000000..ee03d09abf7 --- /dev/null +++ b/homeassistant/components/iometer/config_flow.py @@ -0,0 +1,91 @@ +"""Config flow for the IOmeter integration.""" + +from typing import Any, Final + +from iometer import IOmeterClient, IOmeterConnectionError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from .const import DOMAIN + +CONFIG_SCHEMA: Final = vol.Schema({vol.Required(CONF_HOST): str}) + + +class IOMeterConfigFlow(ConfigFlow, domain=DOMAIN): + """Handles the config flow for a IOmeter bridge and core.""" + + def __init__(self) -> None: + """Initialize the config flow.""" + self._host: str + self._meter_number: str + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + self._host = host = discovery_info.host + self._async_abort_entries_match({CONF_HOST: host}) + + session = async_get_clientsession(self.hass) + client = IOmeterClient(host=host, session=session) + try: + status = await client.get_current_status() + except IOmeterConnectionError: + return self.async_abort(reason="cannot_connect") + + self._meter_number = status.meter.number + + await self.async_set_unique_id(status.device.id) + self._abort_if_unique_id_configured() + + self.context["title_placeholders"] = {"name": f"IOmeter {self._meter_number}"} + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + if user_input is not None: + return await self._async_create_entry() + + self._set_confirm_only() + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={"meter_number": self._meter_number}, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial configuration.""" + errors: dict[str, str] = {} + + if user_input is not None: + self._host = user_input[CONF_HOST] + session = async_get_clientsession(self.hass) + client = IOmeterClient(host=self._host, session=session) + try: + status = await client.get_current_status() + except IOmeterConnectionError: + errors["base"] = "cannot_connect" + else: + self._meter_number = status.meter.number + await self.async_set_unique_id(status.device.id) + self._abort_if_unique_id_configured() + return await self._async_create_entry() + return self.async_show_form( + step_id="user", + data_schema=CONFIG_SCHEMA, + errors=errors, + ) + + async def _async_create_entry(self) -> ConfigFlowResult: + """Create entry.""" + return self.async_create_entry( + title=f"IOmeter {self._meter_number}", + data={CONF_HOST: self._host}, + ) diff --git a/homeassistant/components/iometer/const.py b/homeassistant/components/iometer/const.py new file mode 100644 index 00000000000..797aefcd7f0 --- /dev/null +++ b/homeassistant/components/iometer/const.py @@ -0,0 +1,5 @@ +"""Constants for the IOmeter integration.""" + +from typing import Final + +DOMAIN: Final = "iometer" diff --git a/homeassistant/components/iometer/coordinator.py b/homeassistant/components/iometer/coordinator.py new file mode 100644 index 00000000000..3321b032e4b --- /dev/null +++ b/homeassistant/components/iometer/coordinator.py @@ -0,0 +1,55 @@ +"""DataUpdateCoordinator for IOmeter.""" + +from dataclasses import dataclass +from datetime import timedelta +import logging + +from iometer import IOmeterClient, IOmeterConnectionError, Reading, Status + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +DEFAULT_SCAN_INTERVAL = timedelta(seconds=10) + +type IOmeterConfigEntry = ConfigEntry[IOMeterCoordinator] + + +@dataclass +class IOmeterData: + """Class for data update.""" + + reading: Reading + status: Status + + +class IOMeterCoordinator(DataUpdateCoordinator[IOmeterData]): + """Class to manage fetching IOmeter data.""" + + config_entry: IOmeterConfigEntry + client: IOmeterClient + + def __init__(self, hass: HomeAssistant, client: IOmeterClient) -> None: + """Initialize coordinator.""" + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=DEFAULT_SCAN_INTERVAL, + ) + self.client = client + self.identifier = self.config_entry.entry_id + + async def _async_update_data(self) -> IOmeterData: + """Update data async.""" + try: + reading = await self.client.get_current_reading() + status = await self.client.get_current_status() + except IOmeterConnectionError as error: + raise UpdateFailed(f"Error communicating with IOmeter: {error}") from error + + return IOmeterData(reading=reading, status=status) diff --git a/homeassistant/components/iometer/entity.py b/homeassistant/components/iometer/entity.py new file mode 100644 index 00000000000..86494857e18 --- /dev/null +++ b/homeassistant/components/iometer/entity.py @@ -0,0 +1,24 @@ +"""Base class for IOmeter entities.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import IOMeterCoordinator + + +class IOmeterEntity(CoordinatorEntity[IOMeterCoordinator]): + """Defines a base IOmeter entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: IOMeterCoordinator) -> None: + """Initialize IOmeter entity.""" + super().__init__(coordinator) + status = coordinator.data.status + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, status.device.id)}, + manufacturer="IOmeter GmbH", + model="IOmeter", + sw_version=f"{status.device.core.version}/{status.device.bridge.version}", + ) diff --git a/homeassistant/components/iometer/icons.json b/homeassistant/components/iometer/icons.json new file mode 100644 index 00000000000..8c71684f859 --- /dev/null +++ b/homeassistant/components/iometer/icons.json @@ -0,0 +1,38 @@ +{ + "entity": { + "sensor": { + "attachment_status": { + "default": "mdi:eye", + "state": { + "attached": "mdi:check-bold", + "detached": "mdi:close", + "unknown": "mdi:help" + } + }, + "connection_status": { + "default": "mdi:eye", + "state": { + "connected": "mdi:check-bold", + "disconnected": "mdi:close", + "unknown": "mdi:help" + } + }, + "pin_status": { + "default": "mdi:eye", + "state": { + "entered": "mdi:lock-open", + "pending": "mdi:lock-clock", + "missing": "mdi:lock", + "unknown": "mdi:help" + } + }, + "power_status": { + "default": "mdi:eye", + "state": { + "battery": "mdi:battery", + "wired": "mdi:power-plug" + } + } + } + } +} diff --git a/homeassistant/components/iometer/manifest.json b/homeassistant/components/iometer/manifest.json new file mode 100644 index 00000000000..061a2318e04 --- /dev/null +++ b/homeassistant/components/iometer/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "iometer", + "name": "IOmeter", + "codeowners": ["@MaestroOnICe"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/iometer", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["iometer==0.1.0"], + "zeroconf": ["_iometer._tcp.local."] +} diff --git a/homeassistant/components/iometer/quality_scale.yaml b/homeassistant/components/iometer/quality_scale.yaml new file mode 100644 index 00000000000..71496d8043c --- /dev/null +++ b/homeassistant/components/iometer/quality_scale.yaml @@ -0,0 +1,74 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: This integration does not register any events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: This integration does not provide additional actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration has not option flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: + status: exempt + comment: This integration polls data using a coordinator, there is no need for parallel updates. + reauthentication-flow: + status: exempt + comment: This integration requires no authentication. + test-coverage: todo + + # Gold + devices: todo + diagnostics: todo + discovery-update-info: todo + 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: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/iometer/sensor.py b/homeassistant/components/iometer/sensor.py new file mode 100644 index 00000000000..7d4c1155e8b --- /dev/null +++ b/homeassistant/components/iometer/sensor.py @@ -0,0 +1,146 @@ +"""IOmeter sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS, + STATE_UNKNOWN, + EntityCategory, + UnitOfEnergy, + UnitOfPower, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import IOMeterCoordinator, IOmeterData +from .entity import IOmeterEntity + + +@dataclass(frozen=True, kw_only=True) +class IOmeterEntityDescription(SensorEntityDescription): + """Describes IOmeter sensor entity.""" + + value_fn: Callable[[IOmeterData], str | int | float] + + +SENSOR_TYPES: list[IOmeterEntityDescription] = [ + IOmeterEntityDescription( + key="meter_number", + translation_key="meter_number", + icon="mdi:meter-electric", + value_fn=lambda data: data.status.meter.number, + ), + IOmeterEntityDescription( + key="wifi_rssi", + translation_key="wifi_rssi", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.status.device.bridge.rssi, + ), + IOmeterEntityDescription( + key="core_bridge_rssi", + translation_key="core_bridge_rssi", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.status.device.core.rssi, + ), + IOmeterEntityDescription( + key="power_status", + translation_key="power_status", + device_class=SensorDeviceClass.ENUM, + options=["battery", "wired", "unknown"], + value_fn=lambda data: data.status.device.core.power_status or STATE_UNKNOWN, + ), + IOmeterEntityDescription( + key="battery_level", + translation_key="battery_level", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.status.device.core.battery_level, + ), + IOmeterEntityDescription( + key="pin_status", + translation_key="pin_status", + device_class=SensorDeviceClass.ENUM, + options=["entered", "pending", "missing", "unknown"], + value_fn=lambda data: data.status.device.core.pin_status or STATE_UNKNOWN, + ), + IOmeterEntityDescription( + key="total_consumption", + translation_key="total_consumption", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + value_fn=lambda data: data.reading.get_total_consumption(), + ), + IOmeterEntityDescription( + key="total_production", + translation_key="total_production", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + value_fn=lambda data: data.reading.get_total_production(), + ), + IOmeterEntityDescription( + key="power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.reading.get_current_power(), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Sensors.""" + coordinator: IOMeterCoordinator = config_entry.runtime_data + + async_add_entities( + IOmeterSensor( + coordinator=coordinator, + description=description, + ) + for description in SENSOR_TYPES + ) + + +class IOmeterSensor(IOmeterEntity, SensorEntity): + """Defines a IOmeter sensor.""" + + entity_description: IOmeterEntityDescription + + def __init__( + self, + coordinator: IOMeterCoordinator, + description: IOmeterEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.identifier}_{description.key}" + + @property + def native_value(self) -> StateType: + """Return the sensor value.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/iometer/strings.json b/homeassistant/components/iometer/strings.json new file mode 100644 index 00000000000..31deb16aa9c --- /dev/null +++ b/homeassistant/components/iometer/strings.json @@ -0,0 +1,65 @@ +{ + "config": { + "step": { + "user": { + "description": "Setup your IOmeter device for local data", + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the IOmeter device to connect to." + } + }, + "zeroconf_confirm": { + "title": "Discovered IOmeter", + "description": "Do you want to set up IOmeter on the meter with meter number: {meter_number}?" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "Unexpected error" + } + }, + "entity": { + "sensor": { + "battery_level": { + "name": "Battery level" + }, + "meter_number": { + "name": "Meter number" + }, + "pin_status": { + "name": "PIN status", + "state": { + "entered": "Entered", + "pending": "Pending", + "missing": "Missing", + "unknown": "Unknown" + } + }, + "power_status": { + "name": "Power supply", + "state": { + "battery": "Battery", + "wired": "Wired" + } + }, + "total_consumption": { + "name": "Total consumption" + }, + "total_production": { + "name": "Total production" + }, + "core_bridge_rssi": { + "name": "Signal strength Core/Bridge" + }, + "wifi_rssi": { + "name": "Signal strength Wi-Fi" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 3c8a1d40dc2..d0a8e821f8d 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -289,6 +289,7 @@ FLOWS = { "inkbird", "insteon", "intellifire", + "iometer", "ios", "iotawatt", "iotty", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 57b58e60ed6..026eab30f8f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2929,6 +2929,12 @@ "config_flow": false, "iot_class": "cloud_push" }, + "iometer": { + "name": "IOmeter", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "ios": { "name": "Home Assistant iOS", "integration_type": "hub", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index be15d88aec2..8244f19660f 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -614,6 +614,11 @@ ZEROCONF = { "domain": "homewizard", }, ], + "_iometer._tcp.local.": [ + { + "domain": "iometer", + }, + ], "_ipp._tcp.local.": [ { "domain": "ipp", diff --git a/requirements_all.txt b/requirements_all.txt index 1a6c3c52a4e..2940c9e7f4c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1228,6 +1228,9 @@ insteon-frontend-home-assistant==0.5.0 # homeassistant.components.intellifire intellifire4py==4.1.9 +# homeassistant.components.iometer +iometer==0.1.0 + # homeassistant.components.iotty iottycloud==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2a816f33b5f..8bce9a6839d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1042,6 +1042,9 @@ insteon-frontend-home-assistant==0.5.0 # homeassistant.components.intellifire intellifire4py==4.1.9 +# homeassistant.components.iometer +iometer==0.1.0 + # homeassistant.components.iotty iottycloud==0.3.0 diff --git a/tests/components/iometer/__init__.py b/tests/components/iometer/__init__.py new file mode 100644 index 00000000000..5c08438925e --- /dev/null +++ b/tests/components/iometer/__init__.py @@ -0,0 +1 @@ +"""Tests for the IOmeter integration.""" diff --git a/tests/components/iometer/conftest.py b/tests/components/iometer/conftest.py new file mode 100644 index 00000000000..ee45021952e --- /dev/null +++ b/tests/components/iometer/conftest.py @@ -0,0 +1,57 @@ +"""Common fixtures for the IOmeter tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from iometer import Reading, Status +import pytest + +from homeassistant.components.iometer.const import DOMAIN +from homeassistant.const import CONF_HOST + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.iometer.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_iometer_client() -> Generator[AsyncMock]: + """Mock a new IOmeter client.""" + with ( + patch( + "homeassistant.components.iometer.IOmeterClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.iometer.config_flow.IOmeterClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.host = "10.0.0.2" + client.get_current_reading.return_value = Reading.from_json( + load_fixture("reading.json", DOMAIN) + ) + client.get_current_status.return_value = Status.from_json( + load_fixture("status.json", DOMAIN) + ) + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a IOmeter config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="IOmeter-1ISK0000000000", + data={CONF_HOST: "10.0.0.2"}, + unique_id="658c2b34-2017-45f2-a12b-731235f8bb97", + ) diff --git a/tests/components/iometer/fixtures/reading.json b/tests/components/iometer/fixtures/reading.json new file mode 100644 index 00000000000..82190c88883 --- /dev/null +++ b/tests/components/iometer/fixtures/reading.json @@ -0,0 +1,14 @@ +{ + "__typename": "iometer.reading.v1", + "meter": { + "number": "1ISK0000000000", + "reading": { + "time": "2024-11-11T11:11:11Z", + "registers": [ + { "obis": "01-00:01.08.00*ff", "value": 1234.5, "unit": "Wh" }, + { "obis": "01-00:02.08.00*ff", "value": 5432.1, "unit": "Wh" }, + { "obis": "01-00:10.07.00*ff", "value": 100, "unit": "W" } + ] + } + } +} diff --git a/tests/components/iometer/fixtures/status.json b/tests/components/iometer/fixtures/status.json new file mode 100644 index 00000000000..4d3001d8454 --- /dev/null +++ b/tests/components/iometer/fixtures/status.json @@ -0,0 +1,19 @@ +{ + "__typename": "iometer.status.v1", + "meter": { + "number": "1ISK0000000000" + }, + "device": { + "bridge": { "rssi": -30, "version": "build-65" }, + "id": "658c2b34-2017-45f2-a12b-731235f8bb97", + "core": { + "connectionStatus": "connected", + "rssi": -30, + "version": "build-58", + "powerStatus": "battery", + "batteryLevel": 100, + "attachmentStatus": "attached", + "pinStatus": "entered" + } + } +} diff --git a/tests/components/iometer/test_config_flow.py b/tests/components/iometer/test_config_flow.py new file mode 100644 index 00000000000..49fce459282 --- /dev/null +++ b/tests/components/iometer/test_config_flow.py @@ -0,0 +1,171 @@ +"""Test the IOmeter config flow.""" + +from ipaddress import ip_address +from unittest.mock import AsyncMock + +from iometer import IOmeterConnectionError + +from homeassistant.components import zeroconf +from homeassistant.components.iometer.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +IP_ADDRESS = "10.0.0.2" +IOMETER_DEVICE_ID = "658c2b34-2017-45f2-a12b-731235f8bb97" + +ZEROCONF_DISCOVERY = zeroconf.ZeroconfServiceInfo( + ip_address=ip_address(IP_ADDRESS), + ip_addresses=[ip_address(IP_ADDRESS)], + hostname="IOmeter-EC63E8.local.", + name="IOmeter-EC63E8", + port=80, + type="_iometer._tcp.", + properties={}, +) + + +async def test_user_flow( + hass: HomeAssistant, + mock_iometer_client: AsyncMock, +) -> None: + """Test full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: IP_ADDRESS}, + ) + + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "IOmeter 1ISK0000000000" + assert result["data"] == {CONF_HOST: IP_ADDRESS} + assert result["result"].unique_id == IOMETER_DEVICE_ID + + +async def test_zeroconf_flow( + hass: HomeAssistant, + mock_iometer_client: AsyncMock, +) -> None: + """Test zeroconf flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "IOmeter 1ISK0000000000" + assert result["data"] == {CONF_HOST: IP_ADDRESS} + assert result["result"].unique_id == IOMETER_DEVICE_ID + + +async def test_zeroconf_flow_abort_duplicate( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test zeroconf flow aborts with duplicate.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_flow_connection_error( + hass: HomeAssistant, + mock_iometer_client: AsyncMock, +) -> None: + """Test zeroconf flow.""" + mock_iometer_client.get_current_status.side_effect = IOmeterConnectionError() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_user_flow_connection_error( + hass: HomeAssistant, + mock_iometer_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test flow error.""" + mock_iometer_client.get_current_status.side_effect = IOmeterConnectionError() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: IP_ADDRESS}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_iometer_client.get_current_status.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: IP_ADDRESS}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_flow_abort_duplicate( + hass: HomeAssistant, + mock_iometer_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test duplicate flow.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: IP_ADDRESS}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured"