From 68fbc0792a1ec5d44ce4afccdeaf158d8c594a4e Mon Sep 17 00:00:00 2001 From: Klaas Schoute <klaas_schoute@hotmail.com> Date: Fri, 20 Aug 2021 08:45:04 +0200 Subject: [PATCH] Add P1 Monitor integration (#54738) * Init integration P1 Monitor * Fix build error * Add quality scale * Remove last_reset and icon * Change list to tuple * Close client on connection exception * Change min value to 5 (seconds) * the used python package will close it * Remove the options flow * Add session and close client * Smash to a single DataUpdateCoordinator * Make a custom update coordinator class * await the coordinator close * Add second await the coordinator close * Close when exit scope * Removed unused code * Fix test_sensor on entity_id change * Fix test on test_sensor * Transfer SENSOR dict to sensor platform * device class for cost entity update entity_name * Revert name in unique id and update sensor test * Update code based on suggestions * Fix typing * Change code to fix mypy errors Co-authored-by: Martin Hjelmare <marhje52@gmail.com> --- CODEOWNERS | 1 + .../components/p1_monitor/__init__.py | 91 ++++++ .../components/p1_monitor/config_flow.py | 57 ++++ homeassistant/components/p1_monitor/const.py | 23 ++ .../components/p1_monitor/manifest.json | 10 + homeassistant/components/p1_monitor/sensor.py | 287 ++++++++++++++++++ .../components/p1_monitor/strings.json | 17 ++ .../p1_monitor/translations/en.json | 17 ++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/p1_monitor/__init__.py | 1 + tests/components/p1_monitor/conftest.py | 59 ++++ .../components/p1_monitor/test_config_flow.py | 62 ++++ tests/components/p1_monitor/test_init.py | 44 +++ tests/components/p1_monitor/test_sensor.py | 201 ++++++++++++ tests/fixtures/p1_monitor/phases.json | 74 +++++ tests/fixtures/p1_monitor/settings.json | 27 ++ tests/fixtures/p1_monitor/smartmeter.json | 15 + 19 files changed, 993 insertions(+) create mode 100644 homeassistant/components/p1_monitor/__init__.py create mode 100644 homeassistant/components/p1_monitor/config_flow.py create mode 100644 homeassistant/components/p1_monitor/const.py create mode 100644 homeassistant/components/p1_monitor/manifest.json create mode 100644 homeassistant/components/p1_monitor/sensor.py create mode 100644 homeassistant/components/p1_monitor/strings.json create mode 100644 homeassistant/components/p1_monitor/translations/en.json create mode 100644 tests/components/p1_monitor/__init__.py create mode 100644 tests/components/p1_monitor/conftest.py create mode 100644 tests/components/p1_monitor/test_config_flow.py create mode 100644 tests/components/p1_monitor/test_init.py create mode 100644 tests/components/p1_monitor/test_sensor.py create mode 100644 tests/fixtures/p1_monitor/phases.json create mode 100644 tests/fixtures/p1_monitor/settings.json create mode 100644 tests/fixtures/p1_monitor/smartmeter.json diff --git a/CODEOWNERS b/CODEOWNERS index 3606fade468..6c6a248cdc9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -374,6 +374,7 @@ homeassistant/components/orangepi_gpio/* @pascallj homeassistant/components/oru/* @bvlaicu homeassistant/components/ovo_energy/* @timmo001 homeassistant/components/ozw/* @cgarwood @marcelveldt @MartinHjelmare +homeassistant/components/p1_monitor/* @klaasnicolaas homeassistant/components/panel_custom/* @home-assistant/frontend homeassistant/components/panel_iframe/* @home-assistant/frontend homeassistant/components/pcal9535a/* @Shulyaka diff --git a/homeassistant/components/p1_monitor/__init__.py b/homeassistant/components/p1_monitor/__init__.py new file mode 100644 index 00000000000..5d649fa6d63 --- /dev/null +++ b/homeassistant/components/p1_monitor/__init__.py @@ -0,0 +1,91 @@ +"""The P1 Monitor integration.""" +from __future__ import annotations + +from typing import TypedDict + +from p1monitor import P1Monitor, Phases, Settings, SmartMeter + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +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, + SERVICE_PHASES, + SERVICE_SETTINGS, + SERVICE_SMARTMETER, +) + +PLATFORMS = (SENSOR_DOMAIN,) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up P1 Monitor from a config entry.""" + + coordinator = P1MonitorDataUpdateCoordinator(hass) + try: + await coordinator.async_config_entry_first_refresh() + except ConfigEntryNotReady: + await coordinator.p1monitor.close() + raise + + hass.data.setdefault(DOMAIN, {}) + hass.data[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 P1 Monitor config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + coordinator = hass.data[DOMAIN].pop(entry.entry_id) + await coordinator.p1monitor.close() + return unload_ok + + +class P1MonitorData(TypedDict): + """Class for defining data in dict.""" + + smartmeter: SmartMeter + phases: Phases + settings: Settings + + +class P1MonitorDataUpdateCoordinator(DataUpdateCoordinator[P1MonitorData]): + """Class to manage fetching P1 Monitor data from single endpoint.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + ) -> None: + """Initialize global P1 Monitor data updater.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + self.p1monitor = P1Monitor( + self.config_entry.data[CONF_HOST], session=async_get_clientsession(hass) + ) + + async def _async_update_data(self) -> P1MonitorData: + """Fetch data from P1 Monitor.""" + data: P1MonitorData = { + SERVICE_SMARTMETER: await self.p1monitor.smartmeter(), + SERVICE_PHASES: await self.p1monitor.phases(), + SERVICE_SETTINGS: await self.p1monitor.settings(), + } + + return data diff --git a/homeassistant/components/p1_monitor/config_flow.py b/homeassistant/components/p1_monitor/config_flow.py new file mode 100644 index 00000000000..9e9d695f5e9 --- /dev/null +++ b/homeassistant/components/p1_monitor/config_flow.py @@ -0,0 +1,57 @@ +"""Config flow for P1 Monitor integration.""" +from __future__ import annotations + +from typing import Any + +from p1monitor import P1Monitor, P1MonitorError +import voluptuous as vol + +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 P1MonitorFlowHandler(ConfigFlow, domain=DOMAIN): + """Config flow for P1 Monitor.""" + + VERSION = 1 + + 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: + session = async_get_clientsession(self.hass) + try: + async with P1Monitor( + host=user_input[CONF_HOST], session=session + ) as client: + await client.smartmeter() + except P1MonitorError: + errors["base"] = "cannot_connect" + else: + return self.async_create_entry( + title=user_input[CONF_NAME], + data={ + CONF_HOST: user_input[CONF_HOST], + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Optional( + CONF_NAME, default=self.hass.config.location_name + ): str, + vol.Required(CONF_HOST): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/p1_monitor/const.py b/homeassistant/components/p1_monitor/const.py new file mode 100644 index 00000000000..1af76d49176 --- /dev/null +++ b/homeassistant/components/p1_monitor/const.py @@ -0,0 +1,23 @@ +"""Constants for the P1 Monitor integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Final + +DOMAIN: Final = "p1_monitor" +LOGGER = logging.getLogger(__package__) +SCAN_INTERVAL = timedelta(seconds=5) + +ATTR_ENTRY_TYPE: Final = "entry_type" +ENTRY_TYPE_SERVICE: Final = "service" + +SERVICE_SMARTMETER: Final = "smartmeter" +SERVICE_PHASES: Final = "phases" +SERVICE_SETTINGS: Final = "settings" + +SERVICES: dict[str, str] = { + SERVICE_SMARTMETER: "SmartMeter", + SERVICE_PHASES: "Phases", + SERVICE_SETTINGS: "Settings", +} diff --git a/homeassistant/components/p1_monitor/manifest.json b/homeassistant/components/p1_monitor/manifest.json new file mode 100644 index 00000000000..9e61bca3089 --- /dev/null +++ b/homeassistant/components/p1_monitor/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "p1_monitor", + "name": "P1 Monitor", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/p1_monitor", + "requirements": ["p1monitor == 0.2.0"], + "codeowners": ["@klaasnicolaas"], + "quality_scale": "platinum", + "iot_class": "local_polling" +} \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/sensor.py b/homeassistant/components/p1_monitor/sensor.py new file mode 100644 index 00000000000..36a991c7333 --- /dev/null +++ b/homeassistant/components/p1_monitor/sensor.py @@ -0,0 +1,287 @@ +"""Support for P1 Monitor sensors.""" +from __future__ import annotations + +from typing import Literal + +from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_NAME, + CURRENCY_EURO, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_GAS, + DEVICE_CLASS_MONETARY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_VOLTAGE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_KILO_WATT_HOUR, + POWER_WATT, + VOLUME_CUBIC_METERS, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import P1MonitorDataUpdateCoordinator +from .const import ( + ATTR_ENTRY_TYPE, + DOMAIN, + ENTRY_TYPE_SERVICE, + SERVICE_PHASES, + SERVICE_SETTINGS, + SERVICE_SMARTMETER, + SERVICES, +) + +SENSORS: dict[ + Literal["smartmeter", "phases", "settings"], tuple[SensorEntityDescription, ...] +] = { + SERVICE_SMARTMETER: ( + SensorEntityDescription( + key="gas_consumption", + name="Gas Consumption", + entity_registry_enabled_default=False, + native_unit_of_measurement=VOLUME_CUBIC_METERS, + device_class=DEVICE_CLASS_GAS, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key="power_consumption", + name="Power Consumption", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="energy_consumption_high", + name="Energy Consumption - High Tariff", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key="energy_consumption_low", + name="Energy Consumption - Low Tariff", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key="power_production", + name="Power Production", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="energy_production_high", + name="Energy Production - High Tariff", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key="energy_production_low", + name="Energy Production - Low Tariff", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key="energy_tariff_period", + name="Energy Tariff Period", + icon="mdi:calendar-clock", + ), + ), + SERVICE_PHASES: ( + SensorEntityDescription( + key="voltage_phase_l1", + name="Voltage Phase L1", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="voltage_phase_l2", + name="Voltage Phase L2", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="voltage_phase_l3", + name="Voltage Phase L3", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="current_phase_l1", + name="Current Phase L1", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="current_phase_l2", + name="Current Phase L2", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="current_phase_l3", + name="Current Phase L3", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="power_consumed_phase_l1", + name="Power Consumed Phase L1", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="power_consumed_phase_l2", + name="Power Consumed Phase L2", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="power_consumed_phase_l3", + name="Power Consumed Phase L3", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="power_produced_phase_l1", + name="Power Produced Phase L1", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="power_produced_phase_l2", + name="Power Produced Phase L2", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="power_produced_phase_l3", + name="Power Produced Phase L3", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + ), + SERVICE_SETTINGS: ( + SensorEntityDescription( + key="gas_consumption_tariff", + name="Gas Consumption - Tariff", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_MONETARY, + native_unit_of_measurement=CURRENCY_EURO, + ), + SensorEntityDescription( + key="energy_consumption_low_tariff", + name="Energy Consumption - Low Tariff", + device_class=DEVICE_CLASS_MONETARY, + native_unit_of_measurement=CURRENCY_EURO, + ), + SensorEntityDescription( + key="energy_consumption_high_tariff", + name="Energy Consumption - High Tariff", + device_class=DEVICE_CLASS_MONETARY, + native_unit_of_measurement=CURRENCY_EURO, + ), + SensorEntityDescription( + key="energy_production_low_tariff", + name="Energy Production - Low Tariff", + device_class=DEVICE_CLASS_MONETARY, + native_unit_of_measurement=CURRENCY_EURO, + ), + SensorEntityDescription( + key="energy_production_high_tariff", + name="Energy Production - High Tariff", + device_class=DEVICE_CLASS_MONETARY, + native_unit_of_measurement=CURRENCY_EURO, + ), + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up P1 Monitor Sensors based on a config entry.""" + async_add_entities( + P1MonitorSensorEntity( + coordinator=hass.data[DOMAIN][entry.entry_id], + description=description, + service_key=service_key, + name=entry.title, + service=SERVICES[service_key], + ) + for service_key, service_sensors in SENSORS.items() + for description in service_sensors + ) + + +class P1MonitorSensorEntity(CoordinatorEntity, SensorEntity): + """Defines an P1 Monitor sensor.""" + + coordinator: P1MonitorDataUpdateCoordinator + + def __init__( + self, + *, + coordinator: P1MonitorDataUpdateCoordinator, + description: SensorEntityDescription, + service_key: Literal["smartmeter", "phases", "settings"], + name: str, + service: str, + ) -> None: + """Initialize P1 Monitor sensor.""" + super().__init__(coordinator=coordinator) + self._service_key = service_key + + self.entity_id = f"{SENSOR_DOMAIN}.{name}_{description.key}" + self.entity_description = description + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}_{service_key}_{description.key}" + ) + + self._attr_device_info = { + ATTR_IDENTIFIERS: { + (DOMAIN, f"{coordinator.config_entry.entry_id}_{service_key}") + }, + ATTR_NAME: service, + ATTR_MANUFACTURER: "P1 Monitor", + ATTR_ENTRY_TYPE: ENTRY_TYPE_SERVICE, + } + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + value = getattr( + self.coordinator.data[self._service_key], self.entity_description.key + ) + if isinstance(value, str): + return value.lower() + return value diff --git a/homeassistant/components/p1_monitor/strings.json b/homeassistant/components/p1_monitor/strings.json new file mode 100644 index 00000000000..c28a7129006 --- /dev/null +++ b/homeassistant/components/p1_monitor/strings.json @@ -0,0 +1,17 @@ +{ + "config": { + "step": { + "user": { + "description": "Set up P1 Monitor to integrate with Home Assistant.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "name": "[%key:common::config_flow::data::name%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/p1_monitor/translations/en.json b/homeassistant/components/p1_monitor/translations/en.json new file mode 100644 index 00000000000..34b64082b43 --- /dev/null +++ b/homeassistant/components/p1_monitor/translations/en.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Name" + }, + "description": "Set up P1 Monitor to integrate with Home Assistant." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1ebf71b369f..339bbb1ede3 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -198,6 +198,7 @@ FLOWS = [ "ovo_energy", "owntracks", "ozw", + "p1_monitor", "panasonic_viera", "philips_js", "pi_hole", diff --git a/requirements_all.txt b/requirements_all.txt index 244b4e13188..667a1803924 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1133,6 +1133,9 @@ orvibo==1.1.1 # homeassistant.components.ovo_energy ovoenergy==1.1.12 +# homeassistant.components.p1_monitor +p1monitor == 0.2.0 + # homeassistant.components.mqtt # homeassistant.components.shiftr paho-mqtt==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6557b0d6ce9..f4b934e5a6b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -635,6 +635,9 @@ openerz-api==0.1.0 # homeassistant.components.ovo_energy ovoenergy==1.1.12 +# homeassistant.components.p1_monitor +p1monitor == 0.2.0 + # homeassistant.components.mqtt # homeassistant.components.shiftr paho-mqtt==1.5.1 diff --git a/tests/components/p1_monitor/__init__.py b/tests/components/p1_monitor/__init__.py new file mode 100644 index 00000000000..53a063c5f5b --- /dev/null +++ b/tests/components/p1_monitor/__init__.py @@ -0,0 +1 @@ +"""Tests for the P1 Monitor integration.""" diff --git a/tests/components/p1_monitor/conftest.py b/tests/components/p1_monitor/conftest.py new file mode 100644 index 00000000000..dbdf572c6de --- /dev/null +++ b/tests/components/p1_monitor/conftest.py @@ -0,0 +1,59 @@ +"""Fixtures for P1 Monitor integration tests.""" +import json +from unittest.mock import AsyncMock, MagicMock, patch + +from p1monitor import Phases, Settings, SmartMeter +import pytest + +from homeassistant.components.p1_monitor.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="monitor", + domain=DOMAIN, + data={CONF_HOST: "example"}, + unique_id="unique_thingy", + ) + + +@pytest.fixture +def mock_p1monitor(): + """Return a mocked P1 Monitor client.""" + with patch("homeassistant.components.p1_monitor.P1Monitor") as p1monitor_mock: + client = p1monitor_mock.return_value + client.smartmeter = AsyncMock( + return_value=SmartMeter.from_dict( + json.loads(load_fixture("p1_monitor/smartmeter.json")) + ) + ) + client.phases = AsyncMock( + return_value=Phases.from_dict( + json.loads(load_fixture("p1_monitor/phases.json")) + ) + ) + client.settings = AsyncMock( + return_value=Settings.from_dict( + json.loads(load_fixture("p1_monitor/settings.json")) + ) + ) + yield p1monitor_mock + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_p1monitor: MagicMock +) -> MockConfigEntry: + """Set up the P1 Monitor 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/p1_monitor/test_config_flow.py b/tests/components/p1_monitor/test_config_flow.py new file mode 100644 index 00000000000..f6ce5fe5d9d --- /dev/null +++ b/tests/components/p1_monitor/test_config_flow.py @@ -0,0 +1,62 @@ +"""Test the P1 Monitor config flow.""" +from unittest.mock import patch + +from p1monitor import P1MonitorError + +from homeassistant.components.p1_monitor.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + + +async def test_full_user_flow(hass: HomeAssistant) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + with patch( + "homeassistant.components.p1_monitor.config_flow.P1Monitor.smartmeter" + ) as mock_p1monitor, patch( + "homeassistant.components.p1_monitor.async_setup_entry", return_value=True + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_NAME: "Name", + CONF_HOST: "example.com", + }, + ) + + assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result2.get("title") == "Name" + assert result2.get("data") == { + CONF_HOST: "example.com", + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_p1monitor.mock_calls) == 1 + + +async def test_api_error(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + with patch( + "homeassistant.components.p1_monitor.P1Monitor.smartmeter", + side_effect=P1MonitorError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_NAME: "Name", + CONF_HOST: "example.com", + }, + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("errors") == {"base": "cannot_connect"} diff --git a/tests/components/p1_monitor/test_init.py b/tests/components/p1_monitor/test_init.py new file mode 100644 index 00000000000..bddaff137e6 --- /dev/null +++ b/tests/components/p1_monitor/test_init.py @@ -0,0 +1,44 @@ +"""Tests for the P1 Monitor integration.""" +from unittest.mock import AsyncMock, MagicMock, patch + +from p1monitor import P1MonitorConnectionError + +from homeassistant.components.p1_monitor.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_p1monitor: AsyncMock +) -> None: + """Test the P1 Monitor 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 + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + + +@patch( + "homeassistant.components.p1_monitor.P1Monitor.request", + side_effect=P1MonitorConnectionError, +) +async def test_config_entry_not_ready( + mock_request: MagicMock, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the P1 Monitor 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_request.call_count == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/p1_monitor/test_sensor.py b/tests/components/p1_monitor/test_sensor.py new file mode 100644 index 00000000000..baf73811636 --- /dev/null +++ b/tests/components/p1_monitor/test_sensor.py @@ -0,0 +1,201 @@ +"""Tests for the sensors provided by the P1 Monitor integration.""" +import pytest + +from homeassistant.components.p1_monitor.const import DOMAIN, ENTRY_TYPE_SERVICE +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + CURRENCY_EURO, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_MONETARY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_VOLTAGE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + 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_smartmeter( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test the P1 Monitor - SmartMeter sensors.""" + entry_id = init_integration.entry_id + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + state = hass.states.get("sensor.monitor_power_consumption") + entry = entity_registry.async_get("sensor.monitor_power_consumption") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_smartmeter_power_consumption" + assert state.state == "877" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Power Consumption" + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert ATTR_ICON not in state.attributes + + state = hass.states.get("sensor.monitor_energy_consumption_high") + entry = entity_registry.async_get("sensor.monitor_energy_consumption_high") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_smartmeter_energy_consumption_high" + assert state.state == "2770.133" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Consumption - High Tariff" + ) + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert ATTR_ICON not in state.attributes + + state = hass.states.get("sensor.monitor_energy_tariff_period") + entry = entity_registry.async_get("sensor.monitor_energy_tariff_period") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_smartmeter_energy_tariff_period" + assert state.state == "high" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Tariff Period" + assert state.attributes.get(ATTR_ICON) == "mdi:calendar-clock" + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + assert ATTR_DEVICE_CLASS 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, f"{entry_id}_smartmeter")} + assert device_entry.manufacturer == "P1 Monitor" + assert device_entry.name == "SmartMeter" + assert device_entry.entry_type == ENTRY_TYPE_SERVICE + assert not device_entry.model + assert not device_entry.sw_version + + +async def test_phases( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test the P1 Monitor - Phases sensors.""" + entry_id = init_integration.entry_id + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + state = hass.states.get("sensor.monitor_voltage_phase_l1") + entry = entity_registry.async_get("sensor.monitor_voltage_phase_l1") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_phases_voltage_phase_l1" + assert state.state == "233.6" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Voltage Phase L1" + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ELECTRIC_POTENTIAL_VOLT + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_VOLTAGE + assert ATTR_ICON not in state.attributes + + state = hass.states.get("sensor.monitor_current_phase_l1") + entry = entity_registry.async_get("sensor.monitor_current_phase_l1") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_phases_current_phase_l1" + assert state.state == "1.6" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Current Phase L1" + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ELECTRIC_CURRENT_AMPERE + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_CURRENT + assert ATTR_ICON not in state.attributes + + state = hass.states.get("sensor.monitor_power_consumed_phase_l1") + entry = entity_registry.async_get("sensor.monitor_power_consumed_phase_l1") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_phases_power_consumed_phase_l1" + assert state.state == "315" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Power Consumed Phase L1" + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_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, f"{entry_id}_phases")} + assert device_entry.manufacturer == "P1 Monitor" + assert device_entry.name == "Phases" + assert device_entry.entry_type == ENTRY_TYPE_SERVICE + assert not device_entry.model + assert not device_entry.sw_version + + +async def test_settings( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test the P1 Monitor - Settings sensors.""" + entry_id = init_integration.entry_id + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + state = hass.states.get("sensor.monitor_energy_consumption_low_tariff") + entry = entity_registry.async_get("sensor.monitor_energy_consumption_low_tariff") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_settings_energy_consumption_low_tariff" + assert state.state == "0.20522" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Consumption - Low Tariff" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_MONETARY + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CURRENCY_EURO + + state = hass.states.get("sensor.monitor_energy_production_low_tariff") + entry = entity_registry.async_get("sensor.monitor_energy_production_low_tariff") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_settings_energy_production_low_tariff" + assert state.state == "0.20522" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Production - Low Tariff" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_MONETARY + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CURRENCY_EURO + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, f"{entry_id}_settings")} + assert device_entry.manufacturer == "P1 Monitor" + assert device_entry.name == "Settings" + assert device_entry.entry_type == ENTRY_TYPE_SERVICE + assert not device_entry.model + assert not device_entry.sw_version + + +@pytest.mark.parametrize( + "entity_id", + ("sensor.monitor_gas_consumption",), +) +async def test_smartmeter_disabled_by_default( + hass: HomeAssistant, init_integration: MockConfigEntry, entity_id: str +) -> None: + """Test the P1 Monitor - SmartMeter sensors that are disabled by default.""" + entity_registry = er.async_get(hass) + + state = hass.states.get(entity_id) + assert state is None + + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.disabled + assert entry.disabled_by == er.DISABLED_INTEGRATION diff --git a/tests/fixtures/p1_monitor/phases.json b/tests/fixtures/p1_monitor/phases.json new file mode 100644 index 00000000000..b756f092c05 --- /dev/null +++ b/tests/fixtures/p1_monitor/phases.json @@ -0,0 +1,74 @@ +[ + { + "LABEL": "Huidige KW verbruik L1 (21.7.0)", + "SECURITY": 0, + "STATUS": "0.315", + "STATUS_ID": 74 + }, + { + "LABEL": "Huidige KW verbruik L2 (41.7.0)", + "SECURITY": 0, + "STATUS": "0.0", + "STATUS_ID": 75 + }, + { + "LABEL": "Huidige KW verbruik L3 (61.7.0)", + "SECURITY": 0, + "STATUS": "0.624", + "STATUS_ID": 76 + }, + { + "LABEL": "Huidige KW levering L1 (22.7.0)", + "SECURITY": 0, + "STATUS": "0.0", + "STATUS_ID": 77 + }, + { + "LABEL": "Huidige KW levering L2 (42.7.0)", + "SECURITY": 0, + "STATUS": "0.0", + "STATUS_ID": 78 + }, + { + "LABEL": "Huidige KW levering L3 (62.7.0)", + "SECURITY": 0, + "STATUS": "0.0", + "STATUS_ID": 79 + }, + { + "LABEL": "Huidige Amperage L1 (31.7.0)", + "SECURITY": 0, + "STATUS": "1.6", + "STATUS_ID": 100 + }, + { + "LABEL": "Huidige Amperage L2 (51.7.0)", + "SECURITY": 0, + "STATUS": "4.44", + "STATUS_ID": 101 + }, + { + "LABEL": "Huidige Amperage L2 (71.7.0)", + "SECURITY": 0, + "STATUS": "3.51", + "STATUS_ID": 102 + }, + { + "LABEL": "Huidige Voltage L1 (32.7.0)", + "SECURITY": 0, + "STATUS": "233.6", + "STATUS_ID": 103 + }, + { + "LABEL": "Huidige Voltage L2 (52.7.0)", + "SECURITY": 0, + "STATUS": "0.0", + "STATUS_ID": 104 + }, + { + "LABEL": "Huidige Voltage L2 (72.7.0)", + "SECURITY": 0, + "STATUS": "233.0", + "STATUS_ID": 105 + } +] \ No newline at end of file diff --git a/tests/fixtures/p1_monitor/settings.json b/tests/fixtures/p1_monitor/settings.json new file mode 100644 index 00000000000..eaa14765566 --- /dev/null +++ b/tests/fixtures/p1_monitor/settings.json @@ -0,0 +1,27 @@ +[ + { + "CONFIGURATION_ID": 1, + "LABEL": "Verbruik tarief elektriciteit dal/nacht in euro.", + "PARAMETER": "0.20522" + }, + { + "CONFIGURATION_ID": 2, + "LABEL": "Verbruik tarief elektriciteit piek/dag in euro.", + "PARAMETER": "0.20522" + }, + { + "CONFIGURATION_ID": 3, + "LABEL": "Geleverd tarief elektriciteit dal/nacht in euro.", + "PARAMETER": "0.20522" + }, + { + "CONFIGURATION_ID": 4, + "LABEL": "Geleverd tarief elektriciteit piek/dag in euro.", + "PARAMETER": "0.20522" + }, + { + "CONFIGURATION_ID": 15, + "LABEL": "Verbruik tarief gas in euro.", + "PARAMETER": "0.64" + } +] \ No newline at end of file diff --git a/tests/fixtures/p1_monitor/smartmeter.json b/tests/fixtures/p1_monitor/smartmeter.json new file mode 100644 index 00000000000..d2ca0b38002 --- /dev/null +++ b/tests/fixtures/p1_monitor/smartmeter.json @@ -0,0 +1,15 @@ +[ + { + "CONSUMPTION_GAS_M3": 2273.447, + "CONSUMPTION_KWH_HIGH": 2770.133, + "CONSUMPTION_KWH_LOW": 4988.071, + "CONSUMPTION_W": 877, + "PRODUCTION_KWH_HIGH": 3971.604, + "PRODUCTION_KWH_LOW": 1432.279, + "PRODUCTION_W": 0, + "RECORD_IS_PROCESSED": 0, + "TARIFCODE": "P", + "TIMESTAMP_UTC": 1629134632, + "TIMESTAMP_lOCAL": "2021-08-16 19:23:52" + } +] \ No newline at end of file -- GitLab