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