mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 00:37:53 +00:00
Add Get price service to Nord Pool (#130185)
* Add get_price service to Nord Pool * Tests and fixes * Fixes * Not used fixtures * update qs * Fixes * docstring * Remove selector from strings * Mod service
This commit is contained in:
parent
f8cd6204ca
commit
4c60e36f4f
@ -5,13 +5,25 @@ from __future__ import annotations
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
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 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 Nord Pool 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."""
|
||||
|
@ -38,5 +38,10 @@
|
||||
"default": "mdi:cash-multiple"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"get_prices_for_date": {
|
||||
"service": "mdi:cash-multiple"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -14,27 +14,18 @@ rules:
|
||||
comment: |
|
||||
Entities doesn't subscribe to events.
|
||||
dependency-transparency: done
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
action-setup: done
|
||||
common-modules: done
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
docs-actions: done
|
||||
brands: done
|
||||
# Silver
|
||||
config-entry-unloading: done
|
||||
log-when-unavailable: done
|
||||
entity-unavailable: done
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: |
|
||||
No actions.
|
||||
action-exceptions: done
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: |
|
||||
|
129
homeassistant/components/nordpool/services.py
Normal file
129
homeassistant/components/nordpool/services.py
Normal file
@ -0,0 +1,129 @@
|
||||
"""Services for Nord Pool integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pynordpool import (
|
||||
AREAS,
|
||||
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): vol.All(vol.In(list(AREAS)), cv.ensure_list, [str]),
|
||||
vol.Optional(ATTR_CURRENCY): vol.All(
|
||||
cv.string, vol.In([currency.value for currency in Currency])
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
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] = entry.data[ATTR_AREAS]
|
||||
if _areas := call.data.get(ATTR_AREAS):
|
||||
areas = _areas
|
||||
|
||||
currency: str = entry.data[ATTR_CURRENCY]
|
||||
if _currency := call.data.get(ATTR_CURRENCY):
|
||||
currency = _currency
|
||||
|
||||
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,
|
||||
)
|
48
homeassistant/components/nordpool/services.yaml
Normal file
48
homeassistant/components/nordpool/services.yaml
Normal file
@ -0,0 +1,48 @@
|
||||
get_prices_for_date:
|
||||
fields:
|
||||
config_entry:
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: nordpool
|
||||
date:
|
||||
required: true
|
||||
selector:
|
||||
date:
|
||||
areas:
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- "EE"
|
||||
- "LT"
|
||||
- "LV"
|
||||
- "AT"
|
||||
- "BE"
|
||||
- "FR"
|
||||
- "GER"
|
||||
- "NL"
|
||||
- "PL"
|
||||
- "DK1"
|
||||
- "DK2"
|
||||
- "FI"
|
||||
- "NO1"
|
||||
- "NO2"
|
||||
- "NO3"
|
||||
- "NO4"
|
||||
- "NO5"
|
||||
- "SE1"
|
||||
- "SE2"
|
||||
- "SE3"
|
||||
- "SE4"
|
||||
- "SYS"
|
||||
mode: dropdown
|
||||
currency:
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- "DKK"
|
||||
- "EUR"
|
||||
- "NOK"
|
||||
- "PLN"
|
||||
- "SEK"
|
||||
mode: dropdown
|
@ -70,9 +70,48 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"initial_update_failed": {
|
||||
"message": "Initial update failed on startup with error {error}"
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
127
tests/components/nordpool/snapshots/test_services.ambr
Normal file
127
tests/components/nordpool/snapshots/test_services.ambr
Normal file
@ -0,0 +1,127 @@
|
||||
# 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',
|
||||
}),
|
||||
]),
|
||||
})
|
||||
# ---
|
166
tests/components/nordpool/test_services.py
Normal file
166
tests/components/nordpool/test_services.py
Normal file
@ -0,0 +1,166 @@
|
||||
"""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",
|
||||
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 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"
|
Loading…
x
Reference in New Issue
Block a user