Skip to content
Snippets Groups Projects
Unverified Commit 6c2d6fde authored by Klaas Schoute's avatar Klaas Schoute Committed by GitHub
Browse files

Add Pure Energie integration (#66846)

parent 5359050a
No related branches found
No related tags found
No related merge requests found
Showing
with 654 additions and 0 deletions
......@@ -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.*
......
......@@ -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
......
"""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(),
)
"""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()
"""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)
{
"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
"""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)
{
"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
{
"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
......@@ -257,6 +257,7 @@ FLOWS = [
"progettihwsw",
"prosegur",
"ps4",
"pure_energie",
"pvoutput",
"pvpc_hourly_pricing",
"rachio",
......
......@@ -175,6 +175,10 @@ ZEROCONF = {
"manufacturer": "nettigo"
}
},
{
"domain": "pure_energie",
"name": "smartbridge*"
},
{
"domain": "rachio",
"name": "rachio*"
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
"""Tests for the Pure Energie integration."""
"""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
{"id":"aabbccddeeff","mf":"NET2GRID","model":"SBWF3102","fw":"1.6.16","hw":1,"batch":"SBP-HMX-210318"}
\ No newline at end of file
{"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
"""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"
"""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
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment