From 6c2d6fde66b468c747e2ebbbdbd8b5f92d3fdeff Mon Sep 17 00:00:00 2001 From: Klaas Schoute <klaas_schoute@hotmail.com> Date: Sat, 19 Feb 2022 17:53:25 +0100 Subject: [PATCH] Add Pure Energie integration (#66846) --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/pure_energie/__init__.py | 76 +++++++++++ .../components/pure_energie/config_flow.py | 107 +++++++++++++++ .../components/pure_energie/const.py | 10 ++ .../components/pure_energie/manifest.json | 16 +++ .../components/pure_energie/sensor.py | 113 ++++++++++++++++ .../components/pure_energie/strings.json | 23 ++++ .../pure_energie/translations/en.json | 23 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/zeroconf.py | 4 + mypy.ini | 11 ++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/pure_energie/__init__.py | 1 + tests/components/pure_energie/conftest.py | 83 ++++++++++++ .../pure_energie/fixtures/device.json | 1 + .../pure_energie/fixtures/smartbridge.json | 1 + .../pure_energie/test_config_flow.py | 123 ++++++++++++++++++ tests/components/pure_energie/test_init.py | 52 ++++++++ tests/components/pure_energie/test_sensor.py | 75 +++++++++++ 21 files changed, 729 insertions(+) create mode 100644 homeassistant/components/pure_energie/__init__.py create mode 100644 homeassistant/components/pure_energie/config_flow.py create mode 100644 homeassistant/components/pure_energie/const.py create mode 100644 homeassistant/components/pure_energie/manifest.json create mode 100644 homeassistant/components/pure_energie/sensor.py create mode 100644 homeassistant/components/pure_energie/strings.json create mode 100644 homeassistant/components/pure_energie/translations/en.json create mode 100644 tests/components/pure_energie/__init__.py create mode 100644 tests/components/pure_energie/conftest.py create mode 100644 tests/components/pure_energie/fixtures/device.json create mode 100644 tests/components/pure_energie/fixtures/smartbridge.json create mode 100644 tests/components/pure_energie/test_config_flow.py create mode 100644 tests/components/pure_energie/test_init.py create mode 100644 tests/components/pure_energie/test_sensor.py diff --git a/.strict-typing b/.strict-typing index bd8553359cf..cd148430340 100644 --- a/.strict-typing +++ b/.strict-typing @@ -145,6 +145,7 @@ homeassistant.components.persistent_notification.* homeassistant.components.pi_hole.* homeassistant.components.proximity.* homeassistant.components.pvoutput.* +homeassistant.components.pure_energie.* homeassistant.components.rainmachine.* homeassistant.components.rdw.* homeassistant.components.recollect_waste.* diff --git a/CODEOWNERS b/CODEOWNERS index dabc32c0e11..d80ca44f894 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -738,6 +738,8 @@ tests/components/prosegur/* @dgomes homeassistant/components/proxmoxve/* @jhollowe @Corbeno homeassistant/components/ps4/* @ktnrg45 tests/components/ps4/* @ktnrg45 +homeassistant/components/pure_energie/* @klaasnicolaas +tests/components/pure_energie/* @klaasnicolaas homeassistant/components/push/* @dgomes tests/components/push/* @dgomes homeassistant/components/pvoutput/* @fabaff @frenck diff --git a/homeassistant/components/pure_energie/__init__.py b/homeassistant/components/pure_energie/__init__.py new file mode 100644 index 00000000000..4e86726ccc8 --- /dev/null +++ b/homeassistant/components/pure_energie/__init__.py @@ -0,0 +1,76 @@ +"""The Pure Energie integration.""" +from __future__ import annotations + +from typing import NamedTuple + +from gridnet import Device, GridNet, SmartBridge + +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 homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, LOGGER, SCAN_INTERVAL + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Pure Energie from a config entry.""" + + coordinator = PureEnergieDataUpdateCoordinator(hass) + try: + await coordinator.async_config_entry_first_refresh() + except ConfigEntryNotReady: + await coordinator.gridnet.close() + raise + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Pure Energie config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + del hass.data[DOMAIN][entry.entry_id] + return unload_ok + + +class PureEnergieData(NamedTuple): + """Class for defining data in dict.""" + + device: Device + smartbridge: SmartBridge + + +class PureEnergieDataUpdateCoordinator(DataUpdateCoordinator[PureEnergieData]): + """Class to manage fetching Pure Energie data from single eindpoint.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + ) -> None: + """Initialize global Pure Energie data updater.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + self.gridnet = GridNet( + self.config_entry.data[CONF_HOST], session=async_get_clientsession(hass) + ) + + async def _async_update_data(self) -> PureEnergieData: + """Fetch data from SmartBridge.""" + return PureEnergieData( + device=await self.gridnet.device(), + smartbridge=await self.gridnet.smartbridge(), + ) diff --git a/homeassistant/components/pure_energie/config_flow.py b/homeassistant/components/pure_energie/config_flow.py new file mode 100644 index 00000000000..526bf004fd5 --- /dev/null +++ b/homeassistant/components/pure_energie/config_flow.py @@ -0,0 +1,107 @@ +"""Config flow for Pure Energie integration.""" +from __future__ import annotations + +from typing import Any + +from gridnet import Device, GridNet, GridNetConnectionError +import voluptuous as vol + +from homeassistant.components import zeroconf +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + + +class PureEnergieFlowHandler(ConfigFlow, domain=DOMAIN): + """Config flow for Pure Energie integration.""" + + VERSION = 1 + discovered_host: str + discovered_device: Device + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + + errors = {} + + if user_input is not None: + try: + device = await self._async_get_device(user_input[CONF_HOST]) + except GridNetConnectionError: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(device.n2g_id) + self._abort_if_unique_id_configured( + updates={CONF_HOST: user_input[CONF_HOST]} + ) + return self.async_create_entry( + title="Pure Energie Meter", + data={ + CONF_HOST: user_input[CONF_HOST], + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + } + ), + errors=errors or {}, + ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery.""" + self.discovered_host = discovery_info.host + try: + self.discovered_device = await self._async_get_device(discovery_info.host) + except GridNetConnectionError: + return self.async_abort(reason="cannot_connect") + + await self.async_set_unique_id(self.discovered_device.n2g_id) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host}) + + self.context.update( + { + "title_placeholders": { + CONF_NAME: "Pure Energie Meter", + CONF_HOST: self.discovered_host, + "model": self.discovered_device.model, + }, + } + ) + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by zeroconf.""" + if user_input is not None: + return self.async_create_entry( + title="Pure Energie Meter", + data={ + CONF_HOST: self.discovered_host, + }, + ) + + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={ + CONF_NAME: "Pure Energie Meter", + "model": self.discovered_device.model, + }, + ) + + async def _async_get_device(self, host: str) -> Device: + """Get device information from Pure Energie device.""" + session = async_get_clientsession(self.hass) + gridnet = GridNet(host, session=session) + return await gridnet.device() diff --git a/homeassistant/components/pure_energie/const.py b/homeassistant/components/pure_energie/const.py new file mode 100644 index 00000000000..9c908da6068 --- /dev/null +++ b/homeassistant/components/pure_energie/const.py @@ -0,0 +1,10 @@ +"""Constants for the Pure Energie integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Final + +DOMAIN: Final = "pure_energie" +LOGGER = logging.getLogger(__package__) +SCAN_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/pure_energie/manifest.json b/homeassistant/components/pure_energie/manifest.json new file mode 100644 index 00000000000..7997e9c4b5d --- /dev/null +++ b/homeassistant/components/pure_energie/manifest.json @@ -0,0 +1,16 @@ +{ + "domain": "pure_energie", + "name": "Pure Energie", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/pure_energie", + "requirements": ["gridnet==4.0.0"], + "codeowners": ["@klaasnicolaas"], + "quality_scale": "platinum", + "iot_class": "local_polling", + "zeroconf": [ + { + "type": "_http._tcp.local.", + "name": "smartbridge*" + } + ] +} \ No newline at end of file diff --git a/homeassistant/components/pure_energie/sensor.py b/homeassistant/components/pure_energie/sensor.py new file mode 100644 index 00000000000..64ada3925f3 --- /dev/null +++ b/homeassistant/components/pure_energie/sensor.py @@ -0,0 +1,113 @@ +"""Support for Pure Energie sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, ENERGY_KILO_WATT_HOUR, POWER_WATT +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import PureEnergieData, PureEnergieDataUpdateCoordinator +from .const import DOMAIN + + +@dataclass +class PureEnergieSensorEntityDescriptionMixin: + """Mixin for required keys.""" + + value_fn: Callable[[PureEnergieData], int | float] + + +@dataclass +class PureEnergieSensorEntityDescription( + SensorEntityDescription, PureEnergieSensorEntityDescriptionMixin +): + """Describes a Pure Energie sensor entity.""" + + +SENSORS: tuple[PureEnergieSensorEntityDescription, ...] = ( + PureEnergieSensorEntityDescription( + key="power_flow", + name="Power Flow", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.smartbridge.power_flow, + ), + PureEnergieSensorEntityDescription( + key="energy_consumption_total", + name="Energy Consumption", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.smartbridge.energy_consumption_total, + ), + PureEnergieSensorEntityDescription( + key="energy_production_total", + name="Energy Production", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.smartbridge.energy_production_total, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Pure Energie Sensors based on a config entry.""" + async_add_entities( + PureEnergieSensorEntity( + coordinator=hass.data[DOMAIN][entry.entry_id], + description=description, + entry=entry, + ) + for description in SENSORS + ) + + +class PureEnergieSensorEntity(CoordinatorEntity[PureEnergieData], SensorEntity): + """Defines an Pure Energie sensor.""" + + coordinator: PureEnergieDataUpdateCoordinator + entity_description: PureEnergieSensorEntityDescription + + def __init__( + self, + *, + coordinator: PureEnergieDataUpdateCoordinator, + description: PureEnergieSensorEntityDescription, + entry: ConfigEntry, + ) -> None: + """Initialize Pure Energie sensor.""" + super().__init__(coordinator=coordinator) + self.entity_id = f"{SENSOR_DOMAIN}.pem_{description.key}" + self.entity_description = description + self._attr_unique_id = f"{coordinator.data.device.n2g_id}_{description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, coordinator.data.device.n2g_id)}, + configuration_url=f"http://{coordinator.config_entry.data[CONF_HOST]}", + sw_version=coordinator.data.device.firmware, + manufacturer=coordinator.data.device.manufacturer, + model=coordinator.data.device.model, + name=entry.title, + ) + + @property + def native_value(self) -> int | float: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/pure_energie/strings.json b/homeassistant/components/pure_energie/strings.json new file mode 100644 index 00000000000..356d161f006 --- /dev/null +++ b/homeassistant/components/pure_energie/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "flow_title": "{model} ({host})", + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "zeroconf_confirm": { + "description": "Do you want to add Pure Energie Meter (`{model}`) to Home Assistant?", + "title": "Discovered Pure Energie Meter device" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/pure_energie/translations/en.json b/homeassistant/components/pure_energie/translations/en.json new file mode 100644 index 00000000000..16986efc206 --- /dev/null +++ b/homeassistant/components/pure_energie/translations/en.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "cannot_connect": "Failed to connect" + }, + "error": { + "cannot_connect": "Failed to connect" + }, + "flow_title": "{name} ({host})", + "step": { + "user": { + "data": { + "host": "Host" + } + }, + "zeroconf_confirm": { + "description": "Do you want to add Pure Energie Meter (`{model}`) to Home Assistant?", + "title": "Discovered Pure Energie Meter device" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a4f774e64a7..aa773be82f0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -257,6 +257,7 @@ FLOWS = [ "progettihwsw", "prosegur", "ps4", + "pure_energie", "pvoutput", "pvpc_hourly_pricing", "rachio", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 93a24520497..9c3776155e1 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -175,6 +175,10 @@ ZEROCONF = { "manufacturer": "nettigo" } }, + { + "domain": "pure_energie", + "name": "smartbridge*" + }, { "domain": "rachio", "name": "rachio*" diff --git a/mypy.ini b/mypy.ini index 6187f296ec2..95e2090f061 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1404,6 +1404,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.pure_energie.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.rainmachine.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 073e3c6e819..989addfe730 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -781,6 +781,9 @@ greeneye_monitor==3.0.1 # homeassistant.components.greenwave greenwavereality==0.5.1 +# homeassistant.components.pure_energie +gridnet==4.0.0 + # homeassistant.components.growatt_server growattServer==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c65b43a032e..47217c2c530 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -509,6 +509,9 @@ greeclimate==1.0.2 # homeassistant.components.greeneye_monitor greeneye_monitor==3.0.1 +# homeassistant.components.pure_energie +gridnet==4.0.0 + # homeassistant.components.growatt_server growattServer==1.1.0 diff --git a/tests/components/pure_energie/__init__.py b/tests/components/pure_energie/__init__.py new file mode 100644 index 00000000000..ee7ccbfb483 --- /dev/null +++ b/tests/components/pure_energie/__init__.py @@ -0,0 +1 @@ +"""Tests for the Pure Energie integration.""" diff --git a/tests/components/pure_energie/conftest.py b/tests/components/pure_energie/conftest.py new file mode 100644 index 00000000000..4bb89860ce3 --- /dev/null +++ b/tests/components/pure_energie/conftest.py @@ -0,0 +1,83 @@ +"""Fixtures for Pure Energie integration tests.""" +from collections.abc import Generator +import json +from unittest.mock import AsyncMock, MagicMock, patch + +from gridnet import Device as GridNetDevice, SmartBridge +import pytest + +from homeassistant.components.pure_energie.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="home", + domain=DOMAIN, + data={CONF_HOST: "192.168.1.123"}, + unique_id="unique_thingy", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[None, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.pure_energie.async_setup_entry", return_value=True + ): + yield + + +@pytest.fixture +def mock_pure_energie_config_flow( + request: pytest.FixtureRequest, +) -> Generator[None, MagicMock, None]: + """Return a mocked Pure Energie client.""" + with patch( + "homeassistant.components.pure_energie.config_flow.GridNet", autospec=True + ) as pure_energie_mock: + pure_energie = pure_energie_mock.return_value + pure_energie.device.return_value = GridNetDevice.from_dict( + json.loads(load_fixture("device.json", DOMAIN)) + ) + yield pure_energie + + +@pytest.fixture +def mock_pure_energie(): + """Return a mocked Pure Energie client.""" + with patch( + "homeassistant.components.pure_energie.GridNet", autospec=True + ) as pure_energie_mock: + pure_energie = pure_energie_mock.return_value + pure_energie.smartbridge = AsyncMock( + return_value=SmartBridge.from_dict( + json.loads(load_fixture("pure_energie/smartbridge.json")) + ) + ) + pure_energie.device = AsyncMock( + return_value=GridNetDevice.from_dict( + json.loads(load_fixture("pure_energie/device.json")) + ) + ) + yield pure_energie_mock + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pure_energie: MagicMock, +) -> MockConfigEntry: + """Set up the Pure Energie integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/pure_energie/fixtures/device.json b/tests/components/pure_energie/fixtures/device.json new file mode 100644 index 00000000000..3580d4066ac --- /dev/null +++ b/tests/components/pure_energie/fixtures/device.json @@ -0,0 +1 @@ +{"id":"aabbccddeeff","mf":"NET2GRID","model":"SBWF3102","fw":"1.6.16","hw":1,"batch":"SBP-HMX-210318"} \ No newline at end of file diff --git a/tests/components/pure_energie/fixtures/smartbridge.json b/tests/components/pure_energie/fixtures/smartbridge.json new file mode 100644 index 00000000000..a0268d666ba --- /dev/null +++ b/tests/components/pure_energie/fixtures/smartbridge.json @@ -0,0 +1 @@ +{"status":"ok","elec":{"power":{"now":{"value":338,"unit":"W","time":1634749148},"min":{"value":-7345,"unit":"W","time":1631360893},"max":{"value":13725,"unit":"W","time":1633749513}},"import":{"now":{"value":17762055,"unit":"Wh","time":1634749148}},"export":{"now":{"value":21214589,"unit":"Wh","time":1634749148}}},"gas":{}} \ No newline at end of file diff --git a/tests/components/pure_energie/test_config_flow.py b/tests/components/pure_energie/test_config_flow.py new file mode 100644 index 00000000000..441a5977a2d --- /dev/null +++ b/tests/components/pure_energie/test_config_flow.py @@ -0,0 +1,123 @@ +"""Test the Pure Energie config flow.""" +from unittest.mock import MagicMock + +from gridnet import GridNetConnectionError + +from homeassistant.components import zeroconf +from homeassistant.components.pure_energie.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + + +async def test_full_user_flow_implementation( + hass: HomeAssistant, + mock_pure_energie_config_flow: MagicMock, + mock_setup_entry: None, +) -> None: + """Test the full manual user flow from start to finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result.get("step_id") == SOURCE_USER + assert result.get("type") == RESULT_TYPE_FORM + assert "flow_id" in result + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "192.168.1.123"} + ) + + assert result.get("title") == "Pure Energie Meter" + assert result.get("type") == RESULT_TYPE_CREATE_ENTRY + assert "data" in result + assert result["data"][CONF_HOST] == "192.168.1.123" + assert "result" in result + assert result["result"].unique_id == "aabbccddeeff" + + +async def test_full_zeroconf_flow_implementationn( + hass: HomeAssistant, + mock_pure_energie_config_flow: MagicMock, + mock_setup_entry: None, +) -> None: + """Test the full manual user flow from start to finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="192.168.1.123", + addresses=["192.168.1.123"], + hostname="example.local.", + name="mock_name", + port=None, + properties={CONF_MAC: "aabbccddeeff"}, + type="mock_type", + ), + ) + + assert result.get("description_placeholders") == { + "model": "SBWF3102", + CONF_NAME: "Pure Energie Meter", + } + assert result.get("step_id") == "zeroconf_confirm" + assert result.get("type") == RESULT_TYPE_FORM + assert "flow_id" in result + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result2.get("title") == "Pure Energie Meter" + assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY + + assert "data" in result2 + assert result2["data"][CONF_HOST] == "192.168.1.123" + assert "result" in result2 + assert result2["result"].unique_id == "aabbccddeeff" + + +async def test_connection_error( + hass: HomeAssistant, mock_pure_energie_config_flow: MagicMock +) -> None: + """Test we show user form on Pure Energie connection error.""" + mock_pure_energie_config_flow.device.side_effect = GridNetConnectionError + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: "example.com"}, + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == "user" + assert result.get("errors") == {"base": "cannot_connect"} + + +async def test_zeroconf_connection_error( + hass: HomeAssistant, mock_pure_energie_config_flow: MagicMock +) -> None: + """Test we abort zeroconf flow on Pure Energie connection error.""" + mock_pure_energie_config_flow.device.side_effect = GridNetConnectionError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="192.168.1.123", + addresses=["192.168.1.123"], + hostname="example.local.", + name="mock_name", + port=None, + properties={CONF_MAC: "aabbccddeeff"}, + type="mock_type", + ), + ) + + assert result.get("type") == RESULT_TYPE_ABORT + assert result.get("reason") == "cannot_connect" diff --git a/tests/components/pure_energie/test_init.py b/tests/components/pure_energie/test_init.py new file mode 100644 index 00000000000..c5ffc19c640 --- /dev/null +++ b/tests/components/pure_energie/test_init.py @@ -0,0 +1,52 @@ +"""Tests for the Pure Energie integration.""" +from unittest.mock import AsyncMock, MagicMock, patch + +from gridnet import GridNetConnectionError +import pytest + +from homeassistant.components.pure_energie.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + "mock_pure_energie", ["pure_energie/device.json"], indirect=True +) +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pure_energie: AsyncMock, +) -> None: + """Test the Pure Energie configuration entry loading/unloading.""" + 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 + assert mock_config_entry.unique_id == "unique_thingy" + assert len(mock_pure_energie.mock_calls) == 3 + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@patch( + "homeassistant.components.pure_energie.GridNet.request", + side_effect=GridNetConnectionError, +) +async def test_config_entry_not_ready( + mock_request: MagicMock, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Pure Energie configuration entry not ready.""" + 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/pure_energie/test_sensor.py b/tests/components/pure_energie/test_sensor.py new file mode 100644 index 00000000000..dddafa3c24b --- /dev/null +++ b/tests/components/pure_energie/test_sensor.py @@ -0,0 +1,75 @@ +"""Tests for the sensors provided by the Pure Energie integration.""" + +from homeassistant.components.pure_energie.const import DOMAIN +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + ENERGY_KILO_WATT_HOUR, + POWER_WATT, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_sensors( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test the Pure Energie - SmartBridge sensors.""" + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + state = hass.states.get("sensor.pem_energy_consumption_total") + entry = entity_registry.async_get("sensor.pem_energy_consumption_total") + assert entry + assert state + assert entry.unique_id == "aabbccddeeff_energy_consumption_total" + assert state.state == "17762.1" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Consumption" + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY + assert ATTR_ICON not in state.attributes + + state = hass.states.get("sensor.pem_energy_production_total") + entry = entity_registry.async_get("sensor.pem_energy_production_total") + assert entry + assert state + assert entry.unique_id == "aabbccddeeff_energy_production_total" + assert state.state == "21214.6" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Production" + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY + assert ATTR_ICON not in state.attributes + + state = hass.states.get("sensor.pem_power_flow") + entry = entity_registry.async_get("sensor.pem_power_flow") + assert entry + assert state + assert entry.unique_id == "aabbccddeeff_power_flow" + assert state.state == "338" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Power Flow" + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER + assert ATTR_ICON not in state.attributes + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, "aabbccddeeff")} + assert device_entry.name == "home" + assert device_entry.manufacturer == "NET2GRID" + assert device_entry.entry_type is dr.DeviceEntryType.SERVICE + assert device_entry.model == "SBWF3102" + assert device_entry.sw_version == "1.6.16" -- GitLab