Compare commits

...
Sign in to create a new pull request.

4 commits

Author SHA1 Message Date
G Johansson
6c2adda23a Not used fixtures 2024-11-10 11:20:56 +00:00
G Johansson
d4cedcb4f8 Fixes 2024-11-10 11:19:46 +00:00
G Johansson
e1dd5543e4 Tests and fixes 2024-11-10 10:47:28 +00:00
G Johansson
35c95a1c2b Add get_price service to Nord Pool 2024-11-10 10:47:28 +00:00
7 changed files with 639 additions and 1 deletions

View file

@ -4,13 +4,25 @@ from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from .const import PLATFORMS
from .const import DOMAIN, PLATFORMS
from .coordinator import NordPoolDataUpdateCoordinator
from .services import async_setup_services
type NordPoolConfigEntry = ConfigEntry[NordPoolDataUpdateCoordinator]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Habitica service."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: NordPoolConfigEntry) -> bool:
"""Set up Nord Pool from a config entry."""

View file

@ -38,5 +38,10 @@
"default": "mdi:cash-multiple"
}
}
},
"services": {
"get_prices_for_date": {
"service": "mdi:cash-multiple"
}
}
}

View file

@ -0,0 +1,127 @@
"""Services for Nord Pool integration."""
from __future__ import annotations
from datetime import date, datetime
import logging
from typing import TYPE_CHECKING
from pynordpool import (
Currency,
NordPoolAuthenticationError,
NordPoolEmptyResponseError,
NordPoolError,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_DATE
from homeassistant.core import (
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import ConfigEntrySelector
from homeassistant.util import dt as dt_util
from homeassistant.util.json import JsonValueType
if TYPE_CHECKING:
from . import NordPoolConfigEntry
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
ATTR_CONFIG_ENTRY = "config_entry"
ATTR_AREAS = "areas"
ATTR_CURRENCY = "currency"
SERVICE_GET_PRICES_FOR_DATE = "get_prices_for_date"
SERVICE_GET_PRICES_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(),
vol.Required(ATTR_DATE): cv.date,
vol.Optional(ATTR_AREAS, default=[]): cv.ensure_list,
vol.Optional(ATTR_CURRENCY): cv.string,
}
)
def get_config_entry(hass: HomeAssistant, entry_id: str) -> NordPoolConfigEntry:
"""Return config entry."""
if not (entry := hass.config_entries.async_get_entry(entry_id)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_found",
)
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_loaded",
)
return entry
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services for Nord Pool integration."""
async def get_prices_for_date(call: ServiceCall) -> ServiceResponse:
"""Get price service."""
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
asked_date: date = call.data[ATTR_DATE]
client = entry.runtime_data.client
areas: list[str] | None = call.data.get(ATTR_AREAS)
if not areas:
areas = entry.data[ATTR_AREAS]
currency: str | None = call.data.get(ATTR_CURRENCY)
if not currency:
currency = entry.data[ATTR_CURRENCY]
if TYPE_CHECKING:
assert isinstance(areas, list)
assert isinstance(currency, str)
areas = [area.upper() for area in areas]
currency = currency.upper()
try:
price_data = await client.async_get_delivery_period(
datetime.combine(asked_date, dt_util.utcnow().time()),
Currency(currency),
areas,
)
except NordPoolAuthenticationError as error:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="authentication_error",
) from error
except NordPoolEmptyResponseError as error:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="empty_response",
) from error
except NordPoolError as error:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="connection_error",
) from error
result: dict[str, JsonValueType] = {}
for area in areas:
result[area] = [
{
"start": price_entry.start.isoformat(),
"end": price_entry.end.isoformat(),
"price": price_entry.entry[area],
}
for price_entry in price_data.entries
]
return result
hass.services.async_register(
DOMAIN,
SERVICE_GET_PRICES_FOR_DATE,
get_prices_for_date,
schema=SERVICE_GET_PRICES_SCHEMA,
supports_response=SupportsResponse.ONLY,
)

View file

@ -0,0 +1,26 @@
get_prices_for_date:
fields:
config_entry:
required: true
selector:
config_entry:
integration: nordpool
date:
required: true
selector:
date:
areas:
selector:
text:
multiple: true
currency:
selector:
select:
options:
- "DKK"
- "EUR"
- "NOK"
- "PLN"
- "SEK"
mode: dropdown
translation_key: "currency"

View file

@ -61,5 +61,57 @@
"name": "Daily average"
}
}
},
"services": {
"get_prices_for_date": {
"name": "Get prices for date",
"description": "Retrieve the prices for a specific date.",
"fields": {
"config_entry": {
"name": "Select Nord Pool configuration entry",
"description": "Choose the configuration entry."
},
"date": {
"name": "Date",
"description": "Only dates two months in the past and one day in the future is allowed."
},
"areas": {
"name": "Areas",
"description": "One or multiple areas to get prices for. If left empty it will use the areas already configured."
},
"currency": {
"name": "Currency",
"description": "Currency to get prices in. If left empty it will use the currency already configured."
}
}
}
},
"selector": {
"currency": {
"options": {
"dkk": "DKK",
"eur": "EUR",
"nok": "NOK",
"pln": "PLN",
"sek": "SEK"
}
}
},
"exceptions": {
"entry_not_found": {
"message": "The Nord Pool integration is not configured in Home Assistant."
},
"entry_not_loaded": {
"message": "The Nord Pool integration is currently not loaded or disabled in Home Assistant."
},
"authentication_error": {
"message": "There was an authentication error as you tried to retrieve data too far in the past."
},
"empty_response": {
"message": "Nord Pool has not posted market prices for the provided date."
},
"connection_error": {
"message": "There was a connection error connecting to the API. Try again later."
}
}
}

View file

@ -0,0 +1,249 @@
# serializer version: 1
# name: test_service_call
dict({
'SE3': list([
dict({
'end': '2024-11-05T00:00:00+00:00',
'price': 250.73,
'start': '2024-11-04T23:00:00+00:00',
}),
dict({
'end': '2024-11-05T01:00:00+00:00',
'price': 76.36,
'start': '2024-11-05T00:00:00+00:00',
}),
dict({
'end': '2024-11-05T02:00:00+00:00',
'price': 73.92,
'start': '2024-11-05T01:00:00+00:00',
}),
dict({
'end': '2024-11-05T03:00:00+00:00',
'price': 61.69,
'start': '2024-11-05T02:00:00+00:00',
}),
dict({
'end': '2024-11-05T04:00:00+00:00',
'price': 64.6,
'start': '2024-11-05T03:00:00+00:00',
}),
dict({
'end': '2024-11-05T05:00:00+00:00',
'price': 453.27,
'start': '2024-11-05T04:00:00+00:00',
}),
dict({
'end': '2024-11-05T06:00:00+00:00',
'price': 996.28,
'start': '2024-11-05T05:00:00+00:00',
}),
dict({
'end': '2024-11-05T07:00:00+00:00',
'price': 1406.14,
'start': '2024-11-05T06:00:00+00:00',
}),
dict({
'end': '2024-11-05T08:00:00+00:00',
'price': 1346.54,
'start': '2024-11-05T07:00:00+00:00',
}),
dict({
'end': '2024-11-05T09:00:00+00:00',
'price': 1150.28,
'start': '2024-11-05T08:00:00+00:00',
}),
dict({
'end': '2024-11-05T10:00:00+00:00',
'price': 1031.32,
'start': '2024-11-05T09:00:00+00:00',
}),
dict({
'end': '2024-11-05T11:00:00+00:00',
'price': 927.37,
'start': '2024-11-05T10:00:00+00:00',
}),
dict({
'end': '2024-11-05T12:00:00+00:00',
'price': 925.05,
'start': '2024-11-05T11:00:00+00:00',
}),
dict({
'end': '2024-11-05T13:00:00+00:00',
'price': 949.49,
'start': '2024-11-05T12:00:00+00:00',
}),
dict({
'end': '2024-11-05T14:00:00+00:00',
'price': 1042.03,
'start': '2024-11-05T13:00:00+00:00',
}),
dict({
'end': '2024-11-05T15:00:00+00:00',
'price': 1258.89,
'start': '2024-11-05T14:00:00+00:00',
}),
dict({
'end': '2024-11-05T16:00:00+00:00',
'price': 1816.45,
'start': '2024-11-05T15:00:00+00:00',
}),
dict({
'end': '2024-11-05T17:00:00+00:00',
'price': 2512.65,
'start': '2024-11-05T16:00:00+00:00',
}),
dict({
'end': '2024-11-05T18:00:00+00:00',
'price': 1819.83,
'start': '2024-11-05T17:00:00+00:00',
}),
dict({
'end': '2024-11-05T19:00:00+00:00',
'price': 1011.77,
'start': '2024-11-05T18:00:00+00:00',
}),
dict({
'end': '2024-11-05T20:00:00+00:00',
'price': 835.53,
'start': '2024-11-05T19:00:00+00:00',
}),
dict({
'end': '2024-11-05T21:00:00+00:00',
'price': 796.19,
'start': '2024-11-05T20:00:00+00:00',
}),
dict({
'end': '2024-11-05T22:00:00+00:00',
'price': 522.3,
'start': '2024-11-05T21:00:00+00:00',
}),
dict({
'end': '2024-11-05T23:00:00+00:00',
'price': 289.14,
'start': '2024-11-05T22:00:00+00:00',
}),
]),
'SE4': list([
dict({
'end': '2024-11-05T00:00:00+00:00',
'price': 283.79,
'start': '2024-11-04T23:00:00+00:00',
}),
dict({
'end': '2024-11-05T01:00:00+00:00',
'price': 81.36,
'start': '2024-11-05T00:00:00+00:00',
}),
dict({
'end': '2024-11-05T02:00:00+00:00',
'price': 79.15,
'start': '2024-11-05T01:00:00+00:00',
}),
dict({
'end': '2024-11-05T03:00:00+00:00',
'price': 65.19,
'start': '2024-11-05T02:00:00+00:00',
}),
dict({
'end': '2024-11-05T04:00:00+00:00',
'price': 68.44,
'start': '2024-11-05T03:00:00+00:00',
}),
dict({
'end': '2024-11-05T05:00:00+00:00',
'price': 516.71,
'start': '2024-11-05T04:00:00+00:00',
}),
dict({
'end': '2024-11-05T06:00:00+00:00',
'price': 1240.85,
'start': '2024-11-05T05:00:00+00:00',
}),
dict({
'end': '2024-11-05T07:00:00+00:00',
'price': 1648.25,
'start': '2024-11-05T06:00:00+00:00',
}),
dict({
'end': '2024-11-05T08:00:00+00:00',
'price': 1570.5,
'start': '2024-11-05T07:00:00+00:00',
}),
dict({
'end': '2024-11-05T09:00:00+00:00',
'price': 1345.37,
'start': '2024-11-05T08:00:00+00:00',
}),
dict({
'end': '2024-11-05T10:00:00+00:00',
'price': 1206.51,
'start': '2024-11-05T09:00:00+00:00',
}),
dict({
'end': '2024-11-05T11:00:00+00:00',
'price': 1085.8,
'start': '2024-11-05T10:00:00+00:00',
}),
dict({
'end': '2024-11-05T12:00:00+00:00',
'price': 1081.72,
'start': '2024-11-05T11:00:00+00:00',
}),
dict({
'end': '2024-11-05T13:00:00+00:00',
'price': 1130.38,
'start': '2024-11-05T12:00:00+00:00',
}),
dict({
'end': '2024-11-05T14:00:00+00:00',
'price': 1256.91,
'start': '2024-11-05T13:00:00+00:00',
}),
dict({
'end': '2024-11-05T15:00:00+00:00',
'price': 1765.82,
'start': '2024-11-05T14:00:00+00:00',
}),
dict({
'end': '2024-11-05T16:00:00+00:00',
'price': 2522.55,
'start': '2024-11-05T15:00:00+00:00',
}),
dict({
'end': '2024-11-05T17:00:00+00:00',
'price': 3533.03,
'start': '2024-11-05T16:00:00+00:00',
}),
dict({
'end': '2024-11-05T18:00:00+00:00',
'price': 2524.06,
'start': '2024-11-05T17:00:00+00:00',
}),
dict({
'end': '2024-11-05T19:00:00+00:00',
'price': 1804.46,
'start': '2024-11-05T18:00:00+00:00',
}),
dict({
'end': '2024-11-05T20:00:00+00:00',
'price': 1112.57,
'start': '2024-11-05T19:00:00+00:00',
}),
dict({
'end': '2024-11-05T21:00:00+00:00',
'price': 1051.69,
'start': '2024-11-05T20:00:00+00:00',
}),
dict({
'end': '2024-11-05T22:00:00+00:00',
'price': 662.44,
'start': '2024-11-05T21:00:00+00:00',
}),
dict({
'end': '2024-11-05T23:00:00+00:00',
'price': 349.21,
'start': '2024-11-05T22:00:00+00:00',
}),
]),
})
# ---

View file

@ -0,0 +1,167 @@
"""Test services in Nord Pool."""
from unittest.mock import patch
from pynordpool import (
DeliveryPeriodData,
NordPoolAuthenticationError,
NordPoolEmptyResponseError,
NordPoolError,
)
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.nordpool.const import DOMAIN
from homeassistant.components.nordpool.services import (
ATTR_AREAS,
ATTR_CONFIG_ENTRY,
ATTR_CURRENCY,
SERVICE_GET_PRICES_FOR_DATE,
)
from homeassistant.const import ATTR_DATE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from tests.common import MockConfigEntry
TEST_SERVICE_DATA = {
ATTR_CONFIG_ENTRY: "to_replace",
ATTR_DATE: "2024-11-05",
ATTR_AREAS: ["SE3", "SE4"],
ATTR_CURRENCY: "SEK",
}
TEST_SERVICE_DATA_USE_DEFAULTS = {
ATTR_CONFIG_ENTRY: "to_replace",
ATTR_DATE: "2024-11-05",
}
@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00")
async def test_service_call(
hass: HomeAssistant,
load_int: MockConfigEntry,
get_data: DeliveryPeriodData,
snapshot: SnapshotAssertion,
) -> None:
"""Test get_prices_for_date service call."""
with (
patch(
"homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period",
return_value=get_data,
),
):
service_data = TEST_SERVICE_DATA.copy()
service_data[ATTR_CONFIG_ENTRY] = load_int.entry_id
response = await hass.services.async_call(
DOMAIN,
SERVICE_GET_PRICES_FOR_DATE,
service_data,
blocking=True,
return_response=True,
)
assert response == snapshot
price_value = response["SE3"][0]["price"]
with (
patch(
"homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period",
return_value=get_data,
),
):
service_data = TEST_SERVICE_DATA_USE_DEFAULTS.copy()
service_data[ATTR_CONFIG_ENTRY] = load_int.entry_id
response = await hass.services.async_call(
DOMAIN,
SERVICE_GET_PRICES_FOR_DATE,
service_data,
blocking=True,
return_response=True,
)
assert "SE3" in response
assert "SE4" in response
assert response["SE3"][0]["price"] == price_value
@pytest.mark.parametrize(
("error", "key"),
[
(NordPoolAuthenticationError, "authentication_error"),
(NordPoolEmptyResponseError, "empty_response"),
(NordPoolError, "connection_error"),
],
)
@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00")
async def test_service_call_failures(
hass: HomeAssistant,
load_int: MockConfigEntry,
error: Exception,
key: str,
) -> None:
"""Test get_prices_for_date service call when it fails."""
service_data = TEST_SERVICE_DATA.copy()
service_data[ATTR_CONFIG_ENTRY] = load_int.entry_id
with (
patch(
"homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period",
side_effect=error,
),
pytest.raises(ServiceValidationError) as err,
):
await hass.services.async_call(
DOMAIN,
SERVICE_GET_PRICES_FOR_DATE,
service_data,
blocking=True,
return_response=True,
)
assert err.value.translation_key == key
@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00")
async def test_service_call_config_entry_bad_state(
hass: HomeAssistant,
load_int: MockConfigEntry,
get_data: DeliveryPeriodData,
) -> None:
"""Test get_prices_for_date service call when config entry bad state."""
with (
patch(
"homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period",
return_value=get_data,
),
pytest.raises(ServiceValidationError) as err,
):
await hass.services.async_call(
DOMAIN,
SERVICE_GET_PRICES_FOR_DATE,
TEST_SERVICE_DATA,
blocking=True,
return_response=True,
)
assert err.value.translation_key == "entry_not_found"
service_data = TEST_SERVICE_DATA.copy()
service_data[ATTR_CONFIG_ENTRY] = load_int.entry_id
await hass.config_entries.async_unload(load_int.entry_id)
await hass.async_block_till_done()
with (
patch(
"homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period",
return_value=get_data,
),
pytest.raises(ServiceValidationError) as err,
):
await hass.services.async_call(
DOMAIN,
SERVICE_GET_PRICES_FOR_DATE,
service_data,
blocking=True,
return_response=True,
)
assert err.value.translation_key == "entry_not_loaded"