-
-
Notifications
You must be signed in to change notification settings - Fork 35.8k
Add OMIE integration #150399
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Add OMIE integration #150399
Changes from all commits
4df1683
f8c3065
abafac0
256ad26
1723ce2
799a563
6dd3393
4172692
82cf0cb
4663939
8f389e5
b105fbe
e03ea6e
29d1c53
c43c45a
f3528b3
d550900
cecdf6f
61d454e
df7f65d
ab84fe2
260a081
9cf996b
f0f84cb
18492b4
05aa499
4d86266
ea9756b
0a09354
fe83ce7
2c9bbe4
55d1c65
1822ef1
f62fcf1
496b9fc
501bb76
ec31558
46a451a
cf2718e
4e067cb
87bf260
a6367dc
1272ba2
157e8d6
ab595c8
fda8ec7
c362492
1c58380
29b3eaf
5ed25b4
62a3305
97851e1
04454dc
4a9dce3
da48e0e
4e2f91e
6617fd9
04561e2
e3d3c8a
b3b6216
bad43e9
50dd247
43780ca
15b869f
ce318d6
473bcd5
437976c
e2dcbcd
4201053
104656b
fbdc129
4c42d5b
2a84ebd
b5a5f73
239010e
c402395
d9c0a91
f6148fd
4902dc2
6c2db4b
79152c6
3974e37
5b01c75
6695af7
7fcde94
cdcc61f
b51dd4a
13ca3da
6e6ed07
98fbbca
d8f0dd1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| """The OMIE - Spain and Portugal electricity prices integration.""" | ||
|
|
||
| from homeassistant.const import Platform | ||
| from homeassistant.core import HomeAssistant | ||
|
|
||
| from .coordinator import OMIEConfigEntry, OMIECoordinator | ||
|
|
||
| PLATFORMS = [Platform.SENSOR] | ||
|
|
||
|
|
||
| async def async_setup_entry(hass: HomeAssistant, entry: OMIEConfigEntry) -> bool: | ||
| """Set up from a config entry.""" | ||
| entry.runtime_data = OMIECoordinator(hass, entry) | ||
|
|
||
| await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) | ||
| await entry.runtime_data.async_config_entry_first_refresh() | ||
| return True | ||
|
|
||
|
|
||
| async def async_unload_entry(hass: HomeAssistant, entry: OMIEConfigEntry) -> bool: | ||
| """Unload a config entry.""" | ||
| return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| """Config flow for OMIE - Spain and Portugal electricity prices integration.""" | ||
|
|
||
| from typing import Any, Final | ||
|
|
||
| from homeassistant import config_entries | ||
| from homeassistant.config_entries import ConfigFlowResult | ||
|
|
||
| from .const import DOMAIN | ||
|
|
||
| DEFAULT_NAME: Final = "OMIE" | ||
|
|
||
|
|
||
| class OMIEConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): | ||
| """OMIE config flow.""" | ||
|
|
||
| VERSION = 1 | ||
|
|
||
| async def async_step_user( | ||
| self, user_input: dict[str, Any] | None = None | ||
| ) -> ConfigFlowResult: | ||
| """Handle the first and only step.""" | ||
| if self._async_current_entries(): | ||
| return self.async_abort(reason="single_instance_allowed") | ||
|
|
||
| if user_input is not None: | ||
| return self.async_create_entry(title=DEFAULT_NAME, data={}) | ||
|
|
||
| return self.async_show_form(step_id="user") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| """Constants for the OMIE - Spain and Portugal electricity prices integration.""" | ||
|
|
||
| from typing import Final | ||
|
|
||
| DOMAIN: Final = "omie" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| """Coordinator for the OMIE - Spain and Portugal electricity prices integration.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from collections.abc import Mapping | ||
| import datetime as dt | ||
| import logging | ||
|
|
||
| import pyomie.main as pyomie | ||
| from pyomie.model import OMIEResults, SpotData | ||
|
|
||
| from homeassistant import util | ||
| from homeassistant.config_entries import ConfigEntry | ||
| from homeassistant.core import HomeAssistant | ||
| from homeassistant.helpers.aiohttp_client import async_get_clientsession | ||
| from homeassistant.helpers.update_coordinator import DataUpdateCoordinator | ||
|
|
||
| from .const import DOMAIN | ||
| from .util import CET | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
| _SCHEDULE_MAX_DELAY = dt.timedelta(seconds=10) | ||
| """To avoid thundering herd, we will fetch from OMIE up to this much time after the OMIE data becomes available.""" | ||
|
|
||
| type OMIEConfigEntry = ConfigEntry[OMIECoordinator] | ||
|
|
||
|
|
||
| class OMIECoordinator(DataUpdateCoordinator[Mapping[dt.date, OMIEResults[SpotData]]]): | ||
luuuis marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| """Coordinator that manages OMIE data for yesterday, today, and tomorrow.""" | ||
|
|
||
| def __init__(self, hass: HomeAssistant, config_entry: OMIEConfigEntry) -> None: | ||
| """Initialize OMIE coordinator.""" | ||
| super().__init__( | ||
| hass, | ||
| _LOGGER, | ||
| name=f"{DOMAIN}", | ||
| config_entry=config_entry, | ||
| update_interval=dt.timedelta(minutes=1), | ||
| ) | ||
| self.data = {} | ||
| self._client_session = async_get_clientsession(hass) | ||
|
|
||
| async def _async_update_data(self) -> Mapping[dt.date, OMIEResults[SpotData]]: | ||
| """Update OMIE data, fetching the current CET day.""" | ||
| cet_today = util.dt.now().astimezone(CET).date() | ||
| spot_data = self.data.get(cet_today) or await self.__spot_price(cet_today) | ||
|
|
||
| data = {cet_today: spot_data} if spot_data else {} | ||
| self._set_update_interval() | ||
| _LOGGER.debug("Received data: %s", data) | ||
| return data | ||
|
|
||
| def _set_update_interval(self) -> None: | ||
| """Schedules the next refresh at the start of the next quarter-hour.""" | ||
| now = dt.datetime.now() | ||
| now_quarter_minute = now.minute // 15 * 15 | ||
| refresh_at = now.replace( | ||
| minute=now_quarter_minute, second=0, microsecond=0 | ||
| ) + dt.timedelta(minutes=15) | ||
| self.update_interval = refresh_at - now | ||
|
|
||
| _LOGGER.debug("Next refresh at %s", refresh_at.astimezone()) | ||
|
|
||
| async def __spot_price(self, date: dt.date) -> OMIEResults[SpotData] | None: | ||
| _LOGGER.debug("Fetching OMIE data for %s", date) | ||
| spot_data = await pyomie.spot_price(self._client_session, date) | ||
| _LOGGER.debug("pyomie.spot_price returned: %s", spot_data) | ||
| return spot_data | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| { | ||
| "domain": "omie", | ||
| "name": "OMIE - Spain and Portugal electricity prices", | ||
| "codeowners": ["@luuuis"], | ||
| "config_flow": true, | ||
| "documentation": "https://www.home-assistant.io/integrations/omie", | ||
luuuis marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| "integration_type": "service", | ||
| "iot_class": "cloud_polling", | ||
| "quality_scale": "bronze", | ||
| "requirements": ["pyomie==1.0.1"], | ||
| "single_config_entry": true | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| rules: | ||
| # Bronze | ||
| action-setup: | ||
| status: exempt | ||
| comment: No custom service actions are defined. | ||
| appropriate-polling: done | ||
| brands: done | ||
| common-modules: done | ||
| config-flow-test-coverage: done | ||
| config-flow: done | ||
| dependency-transparency: done | ||
| docs-actions: | ||
| status: exempt | ||
| comment: No custom service actions are defined. | ||
| docs-high-level-description: done | ||
| docs-installation-instructions: done | ||
| docs-removal-instructions: done | ||
| entity-event-setup: | ||
| status: exempt | ||
| comment: No explicit event subscriptions in entity lifecycle. | ||
| entity-unique-id: done | ||
| has-entity-name: done | ||
| runtime-data: done | ||
| test-before-configure: done | ||
| test-before-setup: | ||
| status: exempt | ||
| comment: | | ||
| OMIE API is public data service that doesn't require authentication. | ||
| Coordinators handle any connection issues gracefully during runtime. | ||
| unique-config-entry: | ||
| status: exempt | ||
| comment: | | ||
| Single instance integration that uses _async_current_entries() check. | ||
| Will be updated when adding multiple country support. | ||
|
|
||
| # Silver | ||
| action-exceptions: | ||
| status: exempt | ||
| comment: No custom service actions are defined. | ||
| config-entry-unloading: done | ||
| docs-configuration-parameters: done | ||
| docs-installation-parameters: done | ||
| entity-unavailable: done | ||
| integration-owner: done | ||
| log-when-unavailable: done | ||
| parallel-updates: done | ||
| reauthentication-flow: | ||
| status: exempt | ||
| comment: OMIE API is public data service that doesn't require authentication. | ||
| test-coverage: done |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| """Sensor for the OMIE - Spain and Portugal electricity prices integration.""" | ||
|
|
||
| from dataclasses import dataclass | ||
|
|
||
| from homeassistant.components.sensor import ( | ||
| SensorEntity, | ||
| SensorEntityDescription, | ||
| SensorStateClass, | ||
| ) | ||
| from homeassistant.const import CURRENCY_EURO, UnitOfEnergy | ||
| from homeassistant.core import HomeAssistant, callback | ||
| from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo | ||
| from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback | ||
| from homeassistant.helpers.update_coordinator import CoordinatorEntity | ||
|
|
||
| from . import util | ||
| from .const import DOMAIN | ||
| from .coordinator import OMIEConfigEntry, OMIECoordinator | ||
|
|
||
| PARALLEL_UPDATES = 0 | ||
|
|
||
| _ATTRIBUTION = "Data provided by OMIE.es" | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class OMIEPriceEntityDescription(SensorEntityDescription): | ||
| """Describes OMIE price entities.""" | ||
|
|
||
| def __init__(self, key: str) -> None: | ||
| """Construct an OMIEPriceEntityDescription that reports prices in €/kWh.""" | ||
| super().__init__( | ||
| key=key, | ||
| has_entity_name=True, | ||
| translation_key=key, | ||
| state_class=SensorStateClass.MEASUREMENT, | ||
| native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", | ||
| icon="mdi:currency-eur", | ||
| suggested_display_precision=4, | ||
| ) | ||
|
|
||
|
|
||
| class OMIEPriceSensor(CoordinatorEntity[OMIECoordinator], SensorEntity): | ||
| """OMIE price sensor.""" | ||
|
|
||
| _attr_should_poll = False | ||
| _attr_attribution = _ATTRIBUTION | ||
|
|
||
| def __init__( | ||
| self, | ||
| coordinator: OMIECoordinator, | ||
| device_info: DeviceInfo, | ||
| pyomie_series_name: str, | ||
| ) -> None: | ||
| """Initialize the sensor.""" | ||
| super().__init__(coordinator) | ||
| self.entity_description = OMIEPriceEntityDescription(key=pyomie_series_name) | ||
| self._attr_device_info = device_info | ||
| self._attr_unique_id = pyomie_series_name | ||
| self._pyomie_series_name = pyomie_series_name | ||
|
|
||
| @callback | ||
| def _handle_coordinator_update(self) -> None: | ||
| """Update this sensor's state from the coordinator results.""" | ||
| value = self._get_current_quarter_hour_value() | ||
| self._attr_available = value is not None | ||
| self._attr_native_value = value if self._attr_available else None | ||
| super()._handle_coordinator_update() | ||
|
|
||
| @property | ||
| def available(self) -> bool: | ||
| """Return if entity is available.""" | ||
| return super().available and self._attr_available | ||
|
|
||
| def _get_current_quarter_hour_value(self) -> float | None: | ||
| """Get current quarter-hour's price value from coordinator data.""" | ||
| current_quarter_hour_cet = util.current_quarter_hour_cet() | ||
| current_date_cet = current_quarter_hour_cet.date() | ||
|
|
||
| pyomie_results = self.coordinator.data.get(current_date_cet) | ||
| pyomie_quarter_hours = util.pick_series_cet( | ||
| pyomie_results, self._pyomie_series_name | ||
| ) | ||
|
|
||
| # Convert to €/kWh | ||
| value_mwh = pyomie_quarter_hours.get(current_quarter_hour_cet) | ||
| return value_mwh / 1000 if value_mwh is not None else None | ||
|
|
||
|
|
||
| async def async_setup_entry( | ||
| hass: HomeAssistant, | ||
| entry: OMIEConfigEntry, | ||
| async_add_entities: AddConfigEntryEntitiesCallback, | ||
| ) -> None: | ||
| """Set up OMIE from its config entry.""" | ||
| coordinator = entry.runtime_data | ||
|
|
||
| device_info = DeviceInfo( | ||
| configuration_url="https://www.omie.es/en/market-results", | ||
| entry_type=DeviceEntryType.SERVICE, | ||
| identifiers={(DOMAIN, entry.entry_id)}, | ||
| name="OMIE", | ||
| ) | ||
|
|
||
| sensors = [ | ||
| OMIEPriceSensor(coordinator, device_info, pyomie_series_name="pt_spot_price"), | ||
| OMIEPriceSensor(coordinator, device_info, pyomie_series_name="es_spot_price"), | ||
| ] | ||
|
|
||
| async_add_entities(sensors) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| { | ||
| "config": { | ||
| "abort": { | ||
| "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" | ||
| }, | ||
| "step": { | ||
| "user": { | ||
| "description": "[%key:common::config_flow::description::confirm_setup%]" | ||
| } | ||
| } | ||
| }, | ||
| "entity": { | ||
| "sensor": { | ||
| "es_spot_price": { | ||
| "name": "Spain spot price" | ||
| }, | ||
| "pt_spot_price": { | ||
| "name": "Portugal spot price" | ||
| } | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| """Utility functions for OMIE - Spain and Portugal electricity prices integration.""" | ||
|
|
||
| import datetime as dt | ||
| from typing import Final | ||
| from zoneinfo import ZoneInfo | ||
|
|
||
| from pyomie.model import OMIEResults, _DataT | ||
| from pyomie.util import localize_quarter_hourly_data | ||
|
|
||
| CET: Final = ZoneInfo("CET") | ||
|
|
||
|
|
||
| def current_quarter_hour_cet() -> dt.datetime: | ||
| """Returns the current quarter-hour in CET with seconds and microseconds equal to 0.""" | ||
| now = dt.datetime.now(CET) | ||
| floored_minute = now.minute // 15 * 15 | ||
| return now.replace(minute=floored_minute, second=0, microsecond=0) | ||
|
|
||
|
|
||
| def pick_series_cet( | ||
| res: OMIEResults[_DataT] | None, | ||
| series_name: str, | ||
| ) -> dict[dt.datetime, float]: | ||
| """Pick the values for this series from the market data, keyed by a datetime in CET.""" | ||
| if res is None: | ||
| return {} | ||
|
|
||
| market_date = res.market_date | ||
| series_data = getattr(res.contents, series_name, []) | ||
|
|
||
| return { | ||
| dt.datetime.fromisoformat(dt_str).astimezone(CET): v | ||
| for dt_str, v in localize_quarter_hourly_data(market_date, series_data).items() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Uh oh!
There was an error while loading. Please reload this page.