From 7d54620f343ce5e826aad4c0b705abf501024f92 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Tue, 3 Jan 2023 22:28:16 +0100 Subject: [PATCH] Add EnergyZero integration (#83886) --- CODEOWNERS | 2 + .../components/energyzero/__init__.py | 35 +++ .../components/energyzero/config_flow.py | 31 +++ homeassistant/components/energyzero/const.py | 16 ++ .../components/energyzero/coordinator.py | 80 +++++++ .../components/energyzero/manifest.json | 9 + homeassistant/components/energyzero/sensor.py | 196 +++++++++++++++++ .../components/energyzero/strings.json | 12 ++ .../energyzero/translations/en.json | 12 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/energyzero/__init__.py | 1 + tests/components/energyzero/conftest.py | 61 ++++++ .../energyzero/fixtures/today_energy.json | 104 +++++++++ .../energyzero/fixtures/today_gas.json | 200 ++++++++++++++++++ .../components/energyzero/test_config_flow.py | 32 +++ tests/components/energyzero/test_init.py | 45 ++++ tests/components/energyzero/test_sensor.py | 180 ++++++++++++++++ 20 files changed, 1029 insertions(+) create mode 100644 homeassistant/components/energyzero/__init__.py create mode 100644 homeassistant/components/energyzero/config_flow.py create mode 100644 homeassistant/components/energyzero/const.py create mode 100644 homeassistant/components/energyzero/coordinator.py create mode 100644 homeassistant/components/energyzero/manifest.json create mode 100644 homeassistant/components/energyzero/sensor.py create mode 100644 homeassistant/components/energyzero/strings.json create mode 100644 homeassistant/components/energyzero/translations/en.json create mode 100644 tests/components/energyzero/__init__.py create mode 100644 tests/components/energyzero/conftest.py create mode 100644 tests/components/energyzero/fixtures/today_energy.json create mode 100644 tests/components/energyzero/fixtures/today_gas.json create mode 100644 tests/components/energyzero/test_config_flow.py create mode 100644 tests/components/energyzero/test_init.py create mode 100644 tests/components/energyzero/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 40dc9b4c167..62825fc4279 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -313,6 +313,8 @@ build.json @home-assistant/supervisor /tests/components/emulated_kasa/ @kbickar /homeassistant/components/energy/ @home-assistant/core /tests/components/energy/ @home-assistant/core +/homeassistant/components/energyzero/ @klaasnicolaas +/tests/components/energyzero/ @klaasnicolaas /homeassistant/components/enigma2/ @fbradyirl /homeassistant/components/enocean/ @bdurrer /tests/components/enocean/ @bdurrer diff --git a/homeassistant/components/energyzero/__init__.py b/homeassistant/components/energyzero/__init__.py new file mode 100644 index 00000000000..096e312efc0 --- /dev/null +++ b/homeassistant/components/energyzero/__init__.py @@ -0,0 +1,35 @@ +"""The EnergyZero integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN +from .coordinator import EnergyZeroDataUpdateCoordinator + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up EnergyZero from a config entry.""" + + coordinator = EnergyZeroDataUpdateCoordinator(hass) + try: + await coordinator.async_config_entry_first_refresh() + except ConfigEntryNotReady: + await coordinator.energyzero.close() + raise + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload EnergyZero config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/energyzero/config_flow.py b/homeassistant/components/energyzero/config_flow.py new file mode 100644 index 00000000000..55fffbdec91 --- /dev/null +++ b/homeassistant/components/energyzero/config_flow.py @@ -0,0 +1,31 @@ +"""Config flow for EnergyZero integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigFlow +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + + +class EnergyZeroFlowHandler(ConfigFlow, domain=DOMAIN): + """Config flow for EnergyZero integration.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + + await self.async_set_unique_id(DOMAIN) + self._abort_if_unique_id_configured() + + if user_input is None: + return self.async_show_form(step_id="user") + + return self.async_create_entry( + title="EnergyZero", + data={}, + ) diff --git a/homeassistant/components/energyzero/const.py b/homeassistant/components/energyzero/const.py new file mode 100644 index 00000000000..03d94facf3b --- /dev/null +++ b/homeassistant/components/energyzero/const.py @@ -0,0 +1,16 @@ +"""Constants for the EnergyZero integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Final + +DOMAIN: Final = "energyzero" +LOGGER = logging.getLogger(__package__) +SCAN_INTERVAL = timedelta(minutes=10) +THRESHOLD_HOUR: Final = 14 + +SERVICE_TYPE_DEVICE_NAMES = { + "today_energy": "Energy market price", + "today_gas": "Gas market price", +} diff --git a/homeassistant/components/energyzero/coordinator.py b/homeassistant/components/energyzero/coordinator.py new file mode 100644 index 00000000000..284ae37ce22 --- /dev/null +++ b/homeassistant/components/energyzero/coordinator.py @@ -0,0 +1,80 @@ +"""The Coordinator for EnergyZero.""" +from __future__ import annotations + +from datetime import timedelta +from typing import NamedTuple + +from energyzero import ( + Electricity, + EnergyZero, + EnergyZeroConnectionError, + EnergyZeroNoDataError, + Gas, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt + +from .const import DOMAIN, LOGGER, SCAN_INTERVAL, THRESHOLD_HOUR + + +class EnergyZeroData(NamedTuple): + """Class for defining data in dict.""" + + energy_today: Electricity + energy_tomorrow: Electricity | None + gas_today: Gas | None + + +class EnergyZeroDataUpdateCoordinator(DataUpdateCoordinator[EnergyZeroData]): + """Class to manage fetching EnergyZero data from single endpoint.""" + + config_entry: ConfigEntry + + def __init__(self, hass) -> None: + """Initialize global EnergyZero data updater.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + self.energyzero = EnergyZero(session=async_get_clientsession(hass)) + + async def _async_update_data(self) -> EnergyZeroData: + """Fetch data from EnergyZero.""" + today = dt.now().date() + gas_today = None + energy_tomorrow = None + + try: + energy_today = await self.energyzero.energy_prices( + start_date=today, end_date=today + ) + try: + gas_today = await self.energyzero.gas_prices( + start_date=today, end_date=today + ) + except EnergyZeroNoDataError: + LOGGER.debug("No data for gas prices for EnergyZero integration") + # Energy for tomorrow only after 14:00 UTC + if dt.utcnow().hour >= THRESHOLD_HOUR: + tomorrow = today + timedelta(days=1) + try: + energy_tomorrow = await self.energyzero.energy_prices( + start_date=tomorrow, end_date=tomorrow + ) + except EnergyZeroNoDataError: + LOGGER.debug("No data for tomorrow for EnergyZero integration") + + except EnergyZeroConnectionError as err: + raise UpdateFailed("Error communicating with EnergyZero API") from err + + return EnergyZeroData( + energy_today=energy_today, + energy_tomorrow=energy_tomorrow, + gas_today=gas_today, + ) diff --git a/homeassistant/components/energyzero/manifest.json b/homeassistant/components/energyzero/manifest.json new file mode 100644 index 00000000000..d3f29d6a026 --- /dev/null +++ b/homeassistant/components/energyzero/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "energyzero", + "name": "EnergyZero", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/energyzero", + "requirements": ["energyzero==0.3.1"], + "codeowners": ["@klaasnicolaas"], + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/energyzero/sensor.py b/homeassistant/components/energyzero/sensor.py new file mode 100644 index 00000000000..54cbb7c8195 --- /dev/null +++ b/homeassistant/components/energyzero/sensor.py @@ -0,0 +1,196 @@ +"""Support for EnergyZero sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta + +from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CURRENCY_EURO, PERCENTAGE, UnitOfEnergy, UnitOfVolume +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 .const import DOMAIN, SERVICE_TYPE_DEVICE_NAMES +from .coordinator import EnergyZeroData, EnergyZeroDataUpdateCoordinator + + +@dataclass +class EnergyZeroSensorEntityDescriptionMixin: + """Mixin for required keys.""" + + value_fn: Callable[[EnergyZeroData], float | datetime | None] + service_type: str + + +@dataclass +class EnergyZeroSensorEntityDescription( + SensorEntityDescription, EnergyZeroSensorEntityDescriptionMixin +): + """Describes a Pure Energie sensor entity.""" + + +SENSORS: tuple[EnergyZeroSensorEntityDescription, ...] = ( + EnergyZeroSensorEntityDescription( + key="current_hour_price", + name="Current hour", + service_type="today_gas", + device_class=SensorDeviceClass.MONETARY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfVolume.CUBIC_METERS}", + value_fn=lambda data: data.gas_today.current_price if data.gas_today else None, + ), + EnergyZeroSensorEntityDescription( + key="next_hour_price", + name="Next hour", + service_type="today_gas", + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfVolume.CUBIC_METERS}", + value_fn=lambda data: get_gas_price(data, 1), + ), + EnergyZeroSensorEntityDescription( + key="current_hour_price", + name="Current hour", + service_type="today_energy", + device_class=SensorDeviceClass.MONETARY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", + value_fn=lambda data: data.energy_today.current_price, + ), + EnergyZeroSensorEntityDescription( + key="next_hour_price", + name="Next hour", + service_type="today_energy", + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", + value_fn=lambda data: data.energy_today.price_at_time( + data.energy_today.utcnow() + timedelta(hours=1) + ), + ), + EnergyZeroSensorEntityDescription( + key="average_price", + name="Average - today", + service_type="today_energy", + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", + value_fn=lambda data: data.energy_today.average_price, + ), + EnergyZeroSensorEntityDescription( + key="max_price", + name="Highest price - today", + service_type="today_energy", + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", + value_fn=lambda data: data.energy_today.extreme_prices[1], + ), + EnergyZeroSensorEntityDescription( + key="min_price", + name="Lowest price - today", + service_type="today_energy", + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", + value_fn=lambda data: data.energy_today.extreme_prices[0], + ), + EnergyZeroSensorEntityDescription( + key="highest_price_time", + name="Time of highest price - today", + service_type="today_energy", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.energy_today.highest_price_time, + ), + EnergyZeroSensorEntityDescription( + key="lowest_price_time", + name="Time of lowest price - today", + service_type="today_energy", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.energy_today.lowest_price_time, + ), + EnergyZeroSensorEntityDescription( + key="percentage_of_max", + name="Current percentage of highest price - today", + service_type="today_energy", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + value_fn=lambda data: data.energy_today.pct_of_max_price, + ), +) + + +def get_gas_price(data: EnergyZeroData, hours: int) -> float | None: + """Return the gas value. + + Args: + data: The data object. + hours: The number of hours to add to the current time. + + Returns: + The gas market price value. + """ + if data.gas_today is None: + return None + return data.gas_today.price_at_time( + data.gas_today.utcnow() + timedelta(hours=hours) + ) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up EnergyZero Sensors based on a config entry.""" + coordinator: EnergyZeroDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + EnergyZeroSensorEntity( + coordinator=coordinator, + description=description, + ) + for description in SENSORS + ) + + +class EnergyZeroSensorEntity( + CoordinatorEntity[EnergyZeroDataUpdateCoordinator], SensorEntity +): + """Defines a EnergyZero sensor.""" + + _attr_has_entity_name = True + _attr_attribution = "Data provided by EnergyZero" + entity_description: EnergyZeroSensorEntityDescription + + def __init__( + self, + *, + coordinator: EnergyZeroDataUpdateCoordinator, + description: EnergyZeroSensorEntityDescription, + ) -> None: + """Initialize EnergyZero sensor.""" + super().__init__(coordinator=coordinator) + self.entity_description = description + self.entity_id = ( + f"{SENSOR_DOMAIN}.{DOMAIN}_{description.service_type}_{description.key}" + ) + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.service_type}_{description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={ + ( + DOMAIN, + f"{coordinator.config_entry.entry_id}_{description.service_type}", + ) + }, + manufacturer="EnergyZero", + name=SERVICE_TYPE_DEVICE_NAMES[self.entity_description.service_type], + ) + + @property + def native_value(self) -> float | datetime | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/energyzero/strings.json b/homeassistant/components/energyzero/strings.json new file mode 100644 index 00000000000..ed89e0068d4 --- /dev/null +++ b/homeassistant/components/energyzero/strings.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/energyzero/translations/en.json b/homeassistant/components/energyzero/translations/en.json new file mode 100644 index 00000000000..da9ef89a7af --- /dev/null +++ b/homeassistant/components/energyzero/translations/en.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "step": { + "user": { + "description": "Do you want to start set up?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 47a6e65c1eb..04aaab06fb9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -112,6 +112,7 @@ FLOWS = { "elmax", "emonitor", "emulated_roku", + "energyzero", "enocean", "enphase_envoy", "environment_canada", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ba718159b36..6ae35621b99 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1363,6 +1363,12 @@ "config_flow": true, "iot_class": "local_push" }, + "energyzero": { + "name": "EnergyZero", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "enigma2": { "name": "Enigma2 (OpenWebif)", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 72d05c54968..027cc10caa7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -647,6 +647,9 @@ emulated_roku==0.2.1 # homeassistant.components.huisbaasje energyflip-client==0.2.2 +# homeassistant.components.energyzero +energyzero==0.3.1 + # homeassistant.components.enocean enocean==0.50 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1f76afc9e50..1861a53362d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -500,6 +500,9 @@ emulated_roku==0.2.1 # homeassistant.components.huisbaasje energyflip-client==0.2.2 +# homeassistant.components.energyzero +energyzero==0.3.1 + # homeassistant.components.enocean enocean==0.50 diff --git a/tests/components/energyzero/__init__.py b/tests/components/energyzero/__init__.py new file mode 100644 index 00000000000..287bdf6a2f4 --- /dev/null +++ b/tests/components/energyzero/__init__.py @@ -0,0 +1 @@ +"""Tests for the EnergyZero integration.""" diff --git a/tests/components/energyzero/conftest.py b/tests/components/energyzero/conftest.py new file mode 100644 index 00000000000..42b05eff444 --- /dev/null +++ b/tests/components/energyzero/conftest.py @@ -0,0 +1,61 @@ +"""Fixtures for EnergyZero integration tests.""" +from collections.abc import Generator +import json +from unittest.mock import AsyncMock, MagicMock, patch + +from energyzero import Electricity, Gas +import pytest + +from homeassistant.components.energyzero.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.energyzero.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="energy", + domain=DOMAIN, + data={}, + unique_id="unique_thingy", + ) + + +@pytest.fixture +def mock_energyzero() -> Generator[MagicMock, None, None]: + """Return a mocked EnergyZero client.""" + with patch( + "homeassistant.components.energyzero.coordinator.EnergyZero", autospec=True + ) as energyzero_mock: + client = energyzero_mock.return_value + client.energy_prices.return_value = Electricity.from_dict( + json.loads(load_fixture("today_energy.json", DOMAIN)) + ) + client.gas_prices.return_value = Gas.from_dict( + json.loads(load_fixture("today_gas.json", DOMAIN)) + ) + yield client + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_energyzero: MagicMock +) -> MockConfigEntry: + """Set up the EnergyZero 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/energyzero/fixtures/today_energy.json b/tests/components/energyzero/fixtures/today_energy.json new file mode 100644 index 00000000000..a2139bef0bd --- /dev/null +++ b/tests/components/energyzero/fixtures/today_energy.json @@ -0,0 +1,104 @@ +{ + "Prices": [ + { + "price": 0.35, + "readingDate": "2022-12-06T23:00:00Z" + }, + { + "price": 0.32, + "readingDate": "2022-12-07T00:00:00Z" + }, + { + "price": 0.28, + "readingDate": "2022-12-07T01:00:00Z" + }, + { + "price": 0.26, + "readingDate": "2022-12-07T02:00:00Z" + }, + { + "price": 0.27, + "readingDate": "2022-12-07T03:00:00Z" + }, + { + "price": 0.28, + "readingDate": "2022-12-07T04:00:00Z" + }, + { + "price": 0.28, + "readingDate": "2022-12-07T05:00:00Z" + }, + { + "price": 0.38, + "readingDate": "2022-12-07T06:00:00Z" + }, + { + "price": 0.41, + "readingDate": "2022-12-07T07:00:00Z" + }, + { + "price": 0.46, + "readingDate": "2022-12-07T08:00:00Z" + }, + { + "price": 0.44, + "readingDate": "2022-12-07T09:00:00Z" + }, + { + "price": 0.39, + "readingDate": "2022-12-07T10:00:00Z" + }, + { + "price": 0.33, + "readingDate": "2022-12-07T11:00:00Z" + }, + { + "price": 0.37, + "readingDate": "2022-12-07T12:00:00Z" + }, + { + "price": 0.44, + "readingDate": "2022-12-07T13:00:00Z" + }, + { + "price": 0.48, + "readingDate": "2022-12-07T14:00:00Z" + }, + { + "price": 0.49, + "readingDate": "2022-12-07T15:00:00Z" + }, + { + "price": 0.55, + "readingDate": "2022-12-07T16:00:00Z" + }, + { + "price": 0.37, + "readingDate": "2022-12-07T17:00:00Z" + }, + { + "price": 0.4, + "readingDate": "2022-12-07T18:00:00Z" + }, + { + "price": 0.4, + "readingDate": "2022-12-07T19:00:00Z" + }, + { + "price": 0.32, + "readingDate": "2022-12-07T20:00:00Z" + }, + { + "price": 0.33, + "readingDate": "2022-12-07T21:00:00Z" + }, + { + "price": 0.31, + "readingDate": "2022-12-07T22:00:00Z" + } + ], + "intervalType": 4, + "average": 0.37, + "fromDate": "2022-12-06T23:00:00Z", + "tillDate": "2022-12-07T22:59:59.999Z" +} diff --git a/tests/components/energyzero/fixtures/today_gas.json b/tests/components/energyzero/fixtures/today_gas.json new file mode 100644 index 00000000000..20bd40220aa --- /dev/null +++ b/tests/components/energyzero/fixtures/today_gas.json @@ -0,0 +1,200 @@ +{ + "Prices": [ + { + "price": 1.43, + "readingDate": "2022-12-05T23:00:00Z" + }, + { + "price": 1.43, + "readingDate": "2022-12-06T00:00:00Z" + }, + { + "price": 1.43, + "readingDate": "2022-12-06T01:00:00Z" + }, + { + "price": 1.43, + "readingDate": "2022-12-06T02:00:00Z" + }, + { + "price": 1.43, + "readingDate": "2022-12-06T03:00:00Z" + }, + { + "price": 1.43, + "readingDate": "2022-12-06T04:00:00Z" + }, + { + "price": 1.45, + "readingDate": "2022-12-06T05:00:00Z" + }, + { + "price": 1.45, + "readingDate": "2022-12-06T06:00:00Z" + }, + { + "price": 1.45, + "readingDate": "2022-12-06T07:00:00Z" + }, + { + "price": 1.45, + "readingDate": "2022-12-06T08:00:00Z" + }, + { + "price": 1.45, + "readingDate": "2022-12-06T09:00:00Z" + }, + { + "price": 1.45, + "readingDate": "2022-12-06T10:00:00Z" + }, + { + "price": 1.45, + "readingDate": "2022-12-06T11:00:00Z" + }, + { + "price": 1.45, + "readingDate": "2022-12-06T12:00:00Z" + }, + { + "price": 1.45, + "readingDate": "2022-12-06T13:00:00Z" + }, + { + "price": 1.45, + "readingDate": "2022-12-06T14:00:00Z" + }, + { + "price": 1.45, + "readingDate": "2022-12-06T15:00:00Z" + }, + { + "price": 1.45, + "readingDate": "2022-12-06T16:00:00Z" + }, + { + "price": 1.45, + "readingDate": "2022-12-06T17:00:00Z" + }, + { + "price": 1.45, + "readingDate": "2022-12-06T18:00:00Z" + }, + { + "price": 1.45, + "readingDate": "2022-12-06T19:00:00Z" + }, + { + "price": 1.45, + "readingDate": "2022-12-06T20:00:00Z" + }, + { + "price": 1.45, + "readingDate": "2022-12-06T21:00:00Z" + }, + { + "price": 1.45, + "readingDate": "2022-12-06T22:00:00Z" + }, + { + "price": 1.45, + "readingDate": "2022-12-06T23:00:00Z" + }, + { + "price": 1.45, + "readingDate": "2022-12-07T00:00:00Z" + }, + { + "price": 1.45, + "readingDate": "2022-12-07T01:00:00Z" + }, + { + "price": 1.45, + "readingDate": "2022-12-07T02:00:00Z" + }, + { + "price": 1.45, + "readingDate": "2022-12-07T03:00:00Z" + }, + { + "price": 1.45, + "readingDate": "2022-12-07T04:00:00Z" + }, + { + "price": 1.47, + "readingDate": "2022-12-07T05:00:00Z" + }, + { + "price": 1.47, + "readingDate": "2022-12-07T06:00:00Z" + }, + { + "price": 1.47, + "readingDate": "2022-12-07T07:00:00Z" + }, + { + "price": 1.47, + "readingDate": "2022-12-07T08:00:00Z" + }, + { + "price": 1.47, + "readingDate": "2022-12-07T09:00:00Z" + }, + { + "price": 1.47, + "readingDate": "2022-12-07T10:00:00Z" + }, + { + "price": 1.47, + "readingDate": "2022-12-07T11:00:00Z" + }, + { + "price": 1.47, + "readingDate": "2022-12-07T12:00:00Z" + }, + { + "price": 1.47, + "readingDate": "2022-12-07T13:00:00Z" + }, + { + "price": 1.47, + "readingDate": "2022-12-07T14:00:00Z" + }, + { + "price": 1.47, + "readingDate": "2022-12-07T15:00:00Z" + }, + { + "price": 1.47, + "readingDate": "2022-12-07T16:00:00Z" + }, + { + "price": 1.47, + "readingDate": "2022-12-07T17:00:00Z" + }, + { + "price": 1.47, + "readingDate": "2022-12-07T18:00:00Z" + }, + { + "price": 1.47, + "readingDate": "2022-12-07T19:00:00Z" + }, + { + "price": 1.47, + "readingDate": "2022-12-07T20:00:00Z" + }, + { + "price": 1.47, + "readingDate": "2022-12-07T21:00:00Z" + }, + { + "price": 1.47, + "readingDate": "2022-12-07T22:00:00Z" + } + ], + "intervalType": 4, + "average": 1.46, + "fromDate": "2022-12-06T23:00:00Z", + "tillDate": "2022-12-07T22:59:59.999Z" +} diff --git a/tests/components/energyzero/test_config_flow.py b/tests/components/energyzero/test_config_flow.py new file mode 100644 index 00000000000..b75b0c00dab --- /dev/null +++ b/tests/components/energyzero/test_config_flow.py @@ -0,0 +1,32 @@ +"""Test the EnergyZero config flow.""" +from unittest.mock import MagicMock + +from homeassistant.components.energyzero.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def test_full_user_flow( + hass: HomeAssistant, + mock_setup_entry: MagicMock, +) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result2.get("type") == FlowResultType.CREATE_ENTRY + assert result2.get("title") == "EnergyZero" + assert result2.get("data") == {} + + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/energyzero/test_init.py b/tests/components/energyzero/test_init.py new file mode 100644 index 00000000000..489e4346e25 --- /dev/null +++ b/tests/components/energyzero/test_init.py @@ -0,0 +1,45 @@ +"""Tests for the EnergyZero integration.""" +from unittest.mock import MagicMock, patch + +from energyzero import EnergyZeroConnectionError + +from homeassistant.components.energyzero.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_energyzero: MagicMock +) -> None: + """Test the EnergyZero 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) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@patch( + "homeassistant.components.energyzero.coordinator.EnergyZero._request", + side_effect=EnergyZeroConnectionError, +) +async def test_config_flow_entry_not_ready( + mock_request: MagicMock, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the EnergyZero 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/energyzero/test_sensor.py b/tests/components/energyzero/test_sensor.py new file mode 100644 index 00000000000..f5ab1f5822b --- /dev/null +++ b/tests/components/energyzero/test_sensor.py @@ -0,0 +1,180 @@ +"""Tests for the sensors provided by the EnergyZero integration.""" + +from unittest.mock import MagicMock + +from energyzero import EnergyZeroNoDataError +import pytest + +from homeassistant.components.energyzero.const import DOMAIN +from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + CURRENCY_EURO, + ENERGY_KILO_WATT_HOUR, + STATE_UNKNOWN, + VOLUME_CUBIC_METERS, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +@pytest.mark.freeze_time("2022-12-07 15:00:00") +async def test_energy_today( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Test the EnergyZero - Energy sensors.""" + entry_id = init_integration.entry_id + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + # Current energy price sensor + state = hass.states.get("sensor.energyzero_today_energy_current_hour_price") + entry = entity_registry.async_get( + "sensor.energyzero_today_energy_current_hour_price" + ) + assert entry + assert state + assert entry.unique_id == f"{entry_id}_today_energy_current_hour_price" + assert state.state == "0.49" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy market price Current hour" + ) + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == f"{CURRENCY_EURO}/{ENERGY_KILO_WATT_HOUR}" + ) + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.MONETARY + assert ATTR_ICON not in state.attributes + + # Average price sensor + state = hass.states.get("sensor.energyzero_today_energy_average_price") + entry = entity_registry.async_get("sensor.energyzero_today_energy_average_price") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_today_energy_average_price" + assert state.state == "0.37" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Energy market price Average - today" + ) + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == f"{CURRENCY_EURO}/{ENERGY_KILO_WATT_HOUR}" + ) + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.MONETARY + assert ATTR_ICON not in state.attributes + + # Highest price sensor + state = hass.states.get("sensor.energyzero_today_energy_max_price") + entry = entity_registry.async_get("sensor.energyzero_today_energy_max_price") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_today_energy_max_price" + assert state.state == "0.55" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Energy market price Highest price - today" + ) + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == f"{CURRENCY_EURO}/{ENERGY_KILO_WATT_HOUR}" + ) + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.MONETARY + assert ATTR_ICON not in state.attributes + + # Highest price time sensor + state = hass.states.get("sensor.energyzero_today_energy_highest_price_time") + entry = entity_registry.async_get( + "sensor.energyzero_today_energy_highest_price_time" + ) + assert entry + assert state + assert entry.unique_id == f"{entry_id}_today_energy_highest_price_time" + assert state.state == "2022-12-07T16:00:00+00:00" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Energy market price Time of highest price - today" + ) + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP + 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}_today_energy")} + assert device_entry.manufacturer == "EnergyZero" + assert device_entry.name == "Energy market price" + assert device_entry.entry_type is dr.DeviceEntryType.SERVICE + assert not device_entry.model + assert not device_entry.sw_version + + +@pytest.mark.freeze_time("2022-12-07 15:00:00") +async def test_gas_today( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Test the EnergyZero - Gas sensors.""" + entry_id = init_integration.entry_id + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + # Current gas price sensor + state = hass.states.get("sensor.energyzero_today_gas_current_hour_price") + entry = entity_registry.async_get("sensor.energyzero_today_gas_current_hour_price") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_today_gas_current_hour_price" + assert state.state == "1.47" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Gas market price Current hour" + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == f"{CURRENCY_EURO}/{VOLUME_CUBIC_METERS}" + ) + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.MONETARY + 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}_today_gas")} + assert device_entry.manufacturer == "EnergyZero" + assert device_entry.name == "Gas market price" + assert device_entry.entry_type is dr.DeviceEntryType.SERVICE + assert not device_entry.model + assert not device_entry.sw_version + + +@pytest.mark.freeze_time("2022-12-07 15:00:00") +async def test_no_gas_today( + hass: HomeAssistant, mock_energyzero: MagicMock, init_integration: MockConfigEntry +) -> None: + """Test the EnergyZero - No gas sensors available.""" + await async_setup_component(hass, "homeassistant", {}) + + mock_energyzero.gas_prices.side_effect = EnergyZeroNoDataError + + await hass.services.async_call( + "homeassistant", + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ["sensor.energyzero_today_gas_current_hour_price"]}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energyzero_today_gas_current_hour_price") + assert state + assert state.state == STATE_UNKNOWN