Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
91 commits
Select commit Hold shift + click to select a range
4df1683
Add OMIE integration
luuuis Jan 10, 2024
f8c3065
remove custom_components that was inadvertently comitted
luuuis Jan 15, 2024
abafac0
remove empty elements from manifest.json
luuuis Jan 16, 2024
256ad26
remove yesterday_ attributes because YAGNI
luuuis Jan 18, 2024
1723ce2
fix async_unload_entry and some minor tweaks to __init__.py
luuuis Jan 18, 2024
799a563
removes yesterday from unrecorded_attributes
luuuis Jan 18, 2024
6dd3393
fix permissions on sensor.py
luuuis Jan 19, 2024
4172692
remove unused constants
luuuis Jan 19, 2024
82cf0cb
remove device name translations
luuuis Jan 19, 2024
4663939
use SensorEntityDescription and let HA generate entity id and name
luuuis Jan 19, 2024
8f389e5
remove today/tomorrow_average for now
luuuis Jan 19, 2024
b105fbe
add attribution to sensors
luuuis Jan 19, 2024
e03ea6e
coordinator name matches property name
luuuis Jan 19, 2024
29d1c53
use await in async_setup_entry
luuuis Jan 19, 2024
c43c45a
refactor sensor.py, add util and other style changes
luuuis Jan 19, 2024
f3528b3
refactor OMIEPriceEntity constructor
luuuis Jan 19, 2024
d550900
removed unused var in coordinator.py
luuuis Jan 19, 2024
cecdf6f
removed duplicate omie prefix from coordinator name
luuuis Jan 19, 2024
61d454e
Merge remote-tracking branch 'upstream/dev' into add-omie-integration
luuuis Jan 19, 2024
df7f65d
Merge branch 'dev' into add-omie-integration
luuuis Jan 19, 2024
ab84fe2
Merge remote-tracking branch 'upstream/dev' into add-omie-integration
luuuis Aug 10, 2025
260a081
chore: set quality_scale=legacy for now
luuuis Aug 10, 2025
9cf996b
chore: upgrade to pyomie==0.1.2
luuuis Aug 10, 2025
f0f84cb
test: test config entry init and entry already exists
luuuis Feb 14, 2024
18492b4
fix: update to use async_forward_entry_setups and entry.runtime_data
luuuis Aug 8, 2025
05aa499
fix: ensure unique_id aligns with custom integration
luuuis Aug 10, 2025
4d86266
chore: set quality_scale=bronze
luuuis Aug 10, 2025
ea9756b
fix: missing self._job in Coordinator
luuuis Aug 8, 2025
0a09354
chore: remove extra sensor attributes for simplicity
luuuis Aug 8, 2025
fe83ce7
refactor: overhauled and simplified coordinator logic
luuuis Aug 8, 2025
2c9bbe4
test: added coordinator tests
luuuis Aug 8, 2025
55d1c65
test: added test_coordinator_integration.py
luuuis Aug 8, 2025
1822ef1
chore: pre-commit fixes
luuuis Aug 10, 2025
f62fcf1
Merge branch 'dev' into add-omie-integration
luuuis Aug 10, 2025
496b9fc
feat: use €/kWh for consistency with other integrations
luuuis Aug 10, 2025
501bb76
feat: allow PARALLEL_UPDATES as this is handled by the coordinator
luuuis Aug 11, 2025
ec31558
feat: log if unavailable and when available again
luuuis Aug 11, 2025
46a451a
chore: add silver rules to quality_scale.yaml
luuuis Aug 11, 2025
cf2718e
test: add test_sensor.py
luuuis Aug 14, 2025
4e067cb
test: more tests for coordinator
luuuis Aug 14, 2025
87bf260
Merge branch 'dev' into add-omie-integration
luuuis Aug 14, 2025
a6367dc
refactor: simplify coordinator unavailability logging
luuuis Aug 15, 2025
1272ba2
refactor: use CoordinatorEntity
luuuis Aug 15, 2025
157e8d6
Merge remote-tracking branch 'upstream/dev' into add-omie-integration
luuuis Aug 20, 2025
ab595c8
chore: upgrade to pyomie 0.1.3 for compatible Apache-2.0 license
luuuis Aug 20, 2025
fda8ec7
refactor: introduce _OMIE_PUBLISH_TIME_CET
luuuis Aug 22, 2025
c362492
refactor: remove OMIECoordinator._timezone_provider
luuuis Aug 22, 2025
1c58380
refactor: remove OMIECoordinator._current_time_provider
luuuis Aug 22, 2025
29b3eaf
refactor: create omie/util.py for date functions
luuuis Aug 22, 2025
5ed25b4
refactor: remove OMIECoordinator.spot_price_fetcher
luuuis Aug 22, 2025
62a3305
test: remove bespoke spot_price patching
luuuis Aug 22, 2025
97851e1
fix: remove duplicate sensor refresh during setup
luuuis Aug 22, 2025
04454dc
test: add black-box sensor test for Lisbon & Madrid
luuuis Aug 22, 2025
4a9dce3
doc: typo day-ahead, not intraday market
luuuis Aug 22, 2025
da48e0e
refactor: use PLATFORMS on unload, remove unused code
luuuis Aug 25, 2025
4e2f91e
chore: clean up sensor default names
luuuis Aug 25, 2025
6617fd9
refactor: remove custom scheduling in coordinator
luuuis Aug 25, 2025
04561e2
refactor: pull OMIEPriceSensor out of async_setup_entry for consistency
luuuis Aug 25, 2025
e3d3c8a
refactor: simplified OMIEPriceSensor update handling
luuuis Aug 25, 2025
b3b6216
test: remove unnecessary mock_coordinator_data
luuuis Aug 25, 2025
bad43e9
fix: tightened OMIEPriceSensor unavailable handling
luuuis Aug 25, 2025
50dd247
Merge branch 'dev' into add-omie-integration
abmantis Aug 26, 2025
43780ca
refactor: inlined consts where they are used
luuuis Aug 28, 2025
15b869f
refactor: added OMIEConfigEntry, call async_config_entry_first_refres…
luuuis Aug 28, 2025
ce318d6
refactor: clean up OMIEPriceSensor as per feedback
luuuis Aug 28, 2025
473bcd5
fix: add integration_type to manifest.json
luuuis Aug 28, 2025
437976c
feat: reintroduced simpler custom scheduling (by changing update_inte…
luuuis Aug 28, 2025
e2dcbcd
Merge branch 'dev' into add-omie-integration
luuuis Aug 28, 2025
4201053
refactor: simplify time functions in omie.util
luuuis Sep 11, 2025
104656b
fix: let DataUpdateCoordinator handle unexpected exceptions
luuuis Sep 11, 2025
fbdc129
Merge remote-tracking branch 'upstream/dev' into add-omie-integration
luuuis Sep 11, 2025
4c42d5b
fix: don't override coordinator _schedule_refresh as per feedback
luuuis Sep 11, 2025
2a84ebd
Merge remote-tracking branch 'origin/add-omie-integration' into add-o…
luuuis Sep 11, 2025
b5a5f73
chore: use sentence case for sensor names per Documentation PR feedback
luuuis Sep 11, 2025
239010e
fix: single_config_entry=true
luuuis Sep 12, 2025
c402395
refactor(coordinator): removed separate is_published call
luuuis Sep 12, 2025
d9c0a91
refactor: style changes in current_hour_CET()
luuuis Sep 12, 2025
f6148fd
chore: update to pyomie 1.0.0
luuuis Oct 2, 2025
4902dc2
Merge remote-tracking branch 'upstream/dev' into add-omie-integration
luuuis Oct 2, 2025
6c2db4b
chore: update to pyomie 1.0.1
luuuis Oct 2, 2025
79152c6
chore: adapt to pyomie 1.x quarter-hourly logic
luuuis Oct 2, 2025
3974e37
Merge branch 'dev' into add-omie-integration
luuuis Nov 3, 2025
5b01c75
chore: remove unused get_market_dates
luuuis Nov 4, 2025
6695af7
test: clarify current_quarter_hour_cet() behaviour around DST boundary
luuuis Nov 4, 2025
7fcde94
chore: util.py formatting
luuuis Nov 4, 2025
cdcc61f
chore: omie sensor.py imports
luuuis Nov 4, 2025
b51dd4a
Merge remote-tracking branch 'origin/add-omie-integration' into add-o…
luuuis Nov 4, 2025
13ca3da
Merge remote-tracking branch 'upstream/dev' into add-omie-integration
luuuis Nov 4, 2025
6e6ed07
chore: coordinator.py imports
luuuis Nov 4, 2025
98fbbca
chore: pre-commit ruff/prettier fixes
luuuis Nov 4, 2025
d8f0dd1
refactor: _async_update_data remove multiple days handling
luuuis Nov 4, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CODEOWNERS

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 22 additions & 0 deletions homeassistant/components/omie/__init__.py
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)
28 changes: 28 additions & 0 deletions homeassistant/components/omie/config_flow.py
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")
5 changes: 5 additions & 0 deletions homeassistant/components/omie/const.py
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"
69 changes: 69 additions & 0 deletions homeassistant/components/omie/coordinator.py
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]]]):
"""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
12 changes: 12 additions & 0 deletions homeassistant/components/omie/manifest.json
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",
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["pyomie==1.0.1"],
"single_config_entry": true
}
50 changes: 50 additions & 0 deletions homeassistant/components/omie/quality_scale.yaml
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
109 changes: 109 additions & 0 deletions homeassistant/components/omie/sensor.py
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)
22 changes: 22 additions & 0 deletions homeassistant/components/omie/strings.json
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"
}
}
}
}
34 changes: 34 additions & 0 deletions homeassistant/components/omie/util.py
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()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

v is the price? if so, lets rename it to price

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

v contains the series values depending on the series. There are series for prices, import/export power and so forth, although in practice we are only reading the price-related series into the sensors.

series_value would be an accurate and descriptive variable name, if somewhat verbose.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

series_values (plural) then?
anything that tells what the variable is fine, because v is too opaque.

}
1 change: 1 addition & 0 deletions homeassistant/generated/config_flows.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions homeassistant/generated/integrations.json
Original file line number Diff line number Diff line change
Expand Up @@ -4620,6 +4620,13 @@
"config_flow": false,
"iot_class": "local_polling"
},
"omie": {
"name": "OMIE - Spain and Portugal electricity prices",
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_polling",
"single_config_entry": true
},
"omnilogic": {
"name": "Hayward Omnilogic",
"integration_type": "hub",
Expand Down
Loading