Skip to content

Commit 90ef5b1

Browse files
jaappjoostlek
andauthored
Add Essent dynamic price integration (home-assistant#157010)
Co-authored-by: Joost Lekkerkerker <[email protected]>
1 parent 562f72f commit 90ef5b1

22 files changed

+3964
-0
lines changed

CODEOWNERS

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""The Essent integration."""
2+
3+
from __future__ import annotations
4+
5+
from homeassistant.const import Platform
6+
from homeassistant.core import HomeAssistant
7+
8+
from .coordinator import EssentConfigEntry, EssentDataUpdateCoordinator
9+
10+
PLATFORMS: list[Platform] = [Platform.SENSOR]
11+
12+
13+
async def async_setup_entry(hass: HomeAssistant, entry: EssentConfigEntry) -> bool:
14+
"""Set up Essent from a config entry."""
15+
coordinator = EssentDataUpdateCoordinator(hass, entry)
16+
await coordinator.async_config_entry_first_refresh()
17+
18+
# Start listener updates on the hour to advance cached tariffs
19+
coordinator.start_listener_schedule()
20+
21+
entry.runtime_data = coordinator
22+
23+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
24+
return True
25+
26+
27+
async def async_unload_entry(hass: HomeAssistant, entry: EssentConfigEntry) -> bool:
28+
"""Unload a config entry."""
29+
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Config flow for Essent integration."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from typing import Any
7+
8+
from essent_dynamic_pricing import (
9+
EssentClient,
10+
EssentConnectionError,
11+
EssentDataError,
12+
EssentResponseError,
13+
)
14+
15+
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
16+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
17+
18+
from .const import DOMAIN
19+
20+
_LOGGER = logging.getLogger(__name__)
21+
22+
23+
class EssentConfigFlow(ConfigFlow, domain=DOMAIN):
24+
"""Handle a config flow for Essent."""
25+
26+
VERSION = 1
27+
28+
async def async_step_user(
29+
self, user_input: dict[str, Any] | None = None
30+
) -> ConfigFlowResult:
31+
"""Handle the initial step."""
32+
client = EssentClient(async_get_clientsession(self.hass))
33+
34+
try:
35+
await client.async_get_prices()
36+
except (EssentConnectionError, EssentResponseError):
37+
return self.async_abort(reason="cannot_connect")
38+
except EssentDataError:
39+
return self.async_abort(reason="invalid_data")
40+
except Exception:
41+
_LOGGER.exception("Unexpected error while validating the connection")
42+
return self.async_abort(reason="unknown")
43+
44+
if user_input is None:
45+
return self.async_show_form(step_id="user")
46+
47+
return self.async_create_entry(title="Essent", data={})
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Constants for the Essent integration."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import timedelta
6+
from enum import StrEnum
7+
from typing import Final
8+
9+
DOMAIN: Final = "essent"
10+
UPDATE_INTERVAL: Final = timedelta(hours=12)
11+
ATTRIBUTION: Final = "Data provided by Essent"
12+
13+
14+
class EnergyType(StrEnum):
15+
"""Supported energy types for Essent pricing."""
16+
17+
ELECTRICITY = "electricity"
18+
GAS = "gas"
19+
20+
21+
class PriceGroup(StrEnum):
22+
"""Price group types as provided in tariff groups.
23+
24+
VAT is not emitted as a price group; use tariff.total_amount_vat for VAT.
25+
"""
26+
27+
MARKET_PRICE = "MARKET_PRICE"
28+
PURCHASING_FEE = "PURCHASING_FEE"
29+
TAX = "TAX"
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""DataUpdateCoordinator for Essent integration."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Callable
6+
from datetime import datetime, timedelta
7+
import logging
8+
9+
from essent_dynamic_pricing import (
10+
EssentClient,
11+
EssentConnectionError,
12+
EssentDataError,
13+
EssentError,
14+
EssentPrices,
15+
EssentResponseError,
16+
)
17+
18+
from homeassistant.config_entries import ConfigEntry
19+
from homeassistant.core import HomeAssistant, callback
20+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
21+
from homeassistant.helpers.event import async_track_point_in_utc_time
22+
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
23+
from homeassistant.util import dt as dt_util
24+
25+
from .const import DOMAIN, UPDATE_INTERVAL
26+
27+
_LOGGER = logging.getLogger(__name__)
28+
type EssentConfigEntry = ConfigEntry[EssentDataUpdateCoordinator]
29+
30+
31+
class EssentDataUpdateCoordinator(DataUpdateCoordinator[EssentPrices]):
32+
"""Class to manage fetching Essent data."""
33+
34+
config_entry: EssentConfigEntry
35+
36+
def __init__(self, hass: HomeAssistant, config_entry: EssentConfigEntry) -> None:
37+
"""Initialize."""
38+
super().__init__(
39+
hass,
40+
_LOGGER,
41+
config_entry=config_entry,
42+
name=DOMAIN,
43+
update_interval=UPDATE_INTERVAL,
44+
)
45+
self._client = EssentClient(async_get_clientsession(hass))
46+
self._unsub_listener: Callable[[], None] | None = None
47+
48+
def start_listener_schedule(self) -> None:
49+
"""Start listener tick schedule after first successful data fetch."""
50+
if self.config_entry.pref_disable_polling:
51+
_LOGGER.debug("Polling disabled by config entry, not starting listener")
52+
return
53+
if self._unsub_listener:
54+
return
55+
_LOGGER.info("Starting listener updates on the hour")
56+
self._schedule_listener_tick()
57+
58+
async def async_shutdown(self) -> None:
59+
"""Cancel any scheduled call, and ignore new runs."""
60+
await super().async_shutdown()
61+
if self._unsub_listener:
62+
self._unsub_listener()
63+
self._unsub_listener = None
64+
65+
def _schedule_listener_tick(self) -> None:
66+
"""Schedule listener updates on the hour to advance cached tariffs."""
67+
if self._unsub_listener:
68+
self._unsub_listener()
69+
70+
now = dt_util.utcnow()
71+
next_hour = now + timedelta(hours=1)
72+
next_run = datetime(
73+
next_hour.year,
74+
next_hour.month,
75+
next_hour.day,
76+
next_hour.hour,
77+
tzinfo=dt_util.UTC,
78+
)
79+
80+
_LOGGER.debug("Scheduling next listener tick for %s", next_run)
81+
82+
@callback
83+
def _handle(_: datetime) -> None:
84+
"""Handle the scheduled listener tick to update sensors."""
85+
self._unsub_listener = None
86+
_LOGGER.debug("Listener tick fired, updating sensors with cached data")
87+
self.async_update_listeners()
88+
self._schedule_listener_tick()
89+
90+
self._unsub_listener = async_track_point_in_utc_time(
91+
self.hass,
92+
_handle,
93+
next_run,
94+
)
95+
96+
async def _async_update_data(self) -> EssentPrices:
97+
"""Fetch data from API."""
98+
try:
99+
return await self._client.async_get_prices()
100+
except EssentConnectionError as err:
101+
raise UpdateFailed(f"Error communicating with API: {err}") from err
102+
except EssentResponseError as err:
103+
raise UpdateFailed(str(err)) from err
104+
except EssentDataError as err:
105+
_LOGGER.debug("Invalid data received: %s", err)
106+
raise UpdateFailed(str(err)) from err
107+
except EssentError as err:
108+
raise UpdateFailed("Unexpected Essent error") from err
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Base entity for Essent integration."""
2+
3+
from essent_dynamic_pricing.models import EnergyData
4+
5+
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
6+
from homeassistant.helpers.update_coordinator import CoordinatorEntity
7+
8+
from .const import ATTRIBUTION, DOMAIN, EnergyType
9+
from .coordinator import EssentDataUpdateCoordinator
10+
11+
12+
class EssentEntity(CoordinatorEntity[EssentDataUpdateCoordinator]):
13+
"""Base class for Essent entities."""
14+
15+
_attr_has_entity_name = True
16+
_attr_attribution = ATTRIBUTION
17+
18+
def __init__(
19+
self,
20+
coordinator: EssentDataUpdateCoordinator,
21+
energy_type: EnergyType,
22+
) -> None:
23+
"""Initialize the entity."""
24+
super().__init__(coordinator)
25+
self.energy_type = energy_type
26+
self._attr_device_info = DeviceInfo(
27+
entry_type=DeviceEntryType.SERVICE,
28+
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
29+
name="Essent",
30+
manufacturer="Essent",
31+
)
32+
33+
@property
34+
def energy_data(self) -> EnergyData:
35+
"""Return the energy data for this entity."""
36+
return getattr(self.coordinator.data, self.energy_type.value)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"domain": "essent",
3+
"name": "Essent",
4+
"codeowners": ["@jaapp"],
5+
"config_flow": true,
6+
"documentation": "https://www.home-assistant.io/integrations/essent",
7+
"integration_type": "service",
8+
"iot_class": "cloud_polling",
9+
"quality_scale": "silver",
10+
"requirements": ["essent-dynamic-pricing==0.2.7"],
11+
"single_config_entry": true
12+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
rules:
2+
# Bronze
3+
action-setup:
4+
status: exempt
5+
comment: |
6+
This integration does not provide additional actions or services.
7+
appropriate-polling: done
8+
brands: done
9+
common-modules: done
10+
config-flow-test-coverage: done
11+
config-flow: done
12+
dependency-transparency: done
13+
docs-actions:
14+
status: exempt
15+
comment: |
16+
This integration does not expose services or actions.
17+
docs-high-level-description: done
18+
docs-installation-instructions: done
19+
docs-removal-instructions: done
20+
entity-event-setup:
21+
status: exempt
22+
comment: |
23+
Entities rely on DataUpdateCoordinator updates rather than event subscriptions.
24+
entity-unique-id: done
25+
has-entity-name: done
26+
runtime-data: done
27+
test-before-configure: done
28+
test-before-setup: done
29+
unique-config-entry: done
30+
31+
# Silver
32+
action-exceptions:
33+
status: exempt
34+
comment: |
35+
This integration does not expose user actions.
36+
config-entry-unloading: done
37+
docs-configuration-parameters:
38+
status: exempt
39+
comment: |
40+
No options flow is provided.
41+
docs-installation-parameters: done
42+
entity-unavailable: done
43+
integration-owner: done
44+
log-when-unavailable: done
45+
parallel-updates: done
46+
reauthentication-flow:
47+
status: exempt
48+
comment: |
49+
No authentication is required for this integration.
50+
test-coverage: done
51+
# Gold
52+
devices: done
53+
diagnostics: todo
54+
discovery-update-info: todo
55+
discovery: todo
56+
docs-data-update: todo
57+
docs-examples: todo
58+
docs-known-limitations: todo
59+
docs-supported-devices: todo
60+
docs-supported-functions: todo
61+
docs-troubleshooting: todo
62+
docs-use-cases: todo
63+
dynamic-devices:
64+
status: exempt
65+
comment: |
66+
Device-less integration.
67+
entity-category: done
68+
entity-device-class: done
69+
entity-disabled-by-default: done
70+
entity-translations: done
71+
exception-translations: todo
72+
icon-translations:
73+
status: exempt
74+
comment: |
75+
No custom icons are defined.
76+
reconfiguration-flow: todo
77+
repair-issues:
78+
status: exempt
79+
comment: |
80+
No known repair flows at this time.
81+
stale-devices:
82+
status: exempt
83+
comment: |
84+
Device-less integration.
85+
86+
# Platinum
87+
async-dependency: done
88+
inject-websession: done
89+
strict-typing: todo

0 commit comments

Comments
 (0)