summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/publish.yml36
-rw-r--r--.gitignore5
-rw-r--r--HA_INTEGRATION_CHANGES.md117
-rw-r--r--LICENSE21
-rw-r--r--README.md53
-rw-r--r--debian/changelog5
-rw-r--r--debian/control40
-rw-r--r--debian/copyright35
-rw-r--r--debian/docs2
-rwxr-xr-xdebian/rules3
-rw-r--r--debian/salsa-ci.yml3
-rw-r--r--debian/source/format1
-rw-r--r--debian/upstream/metadata4
-rw-r--r--debian/watch5
-rw-r--r--pyproject.toml34
-rw-r--r--src/essent_dynamic_pricing/__init__.py21
-rw-r--r--src/essent_dynamic_pricing/client.py204
-rw-r--r--src/essent_dynamic_pricing/exceptions.py16
-rw-r--r--src/essent_dynamic_pricing/models.py84
-rw-r--r--src/essent_dynamic_pricing/py.typed1
-rw-r--r--tests/test_client.py568
21 files changed, 1258 insertions, 0 deletions
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..6e12ab4
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,36 @@
+name: Publish to PyPI
+
+on:
+ release:
+ types: [published]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ id-token: write
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.13"
+ cache: pip
+
+ - name: Install build tooling
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install build
+
+ - name: Build distribution
+ run: python -m build
+
+ - name: Publish to PyPI
+ uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ packages-dir: dist/
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..88ea843
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+.venv/
+dist/
+__pycache__/
+*.pyc
+.coverage
diff --git a/HA_INTEGRATION_CHANGES.md b/HA_INTEGRATION_CHANGES.md
new file mode 100644
index 0000000..3df91b1
--- /dev/null
+++ b/HA_INTEGRATION_CHANGES.md
@@ -0,0 +1,117 @@
+# Home Assistant Integration Changes Required
+
+This document describes the changes needed in the Home Assistant `essent` integration to support `essent-dynamic-pricing` v0.3.0.
+
+## Breaking Change Summary
+
+**Library v0.3.0** makes the `gas` field optional in `EssentPrices`:
+
+```python
+# BEFORE (v0.2.x)
+@dataclass
+class EssentPrices:
+ electricity: EnergyData # Required
+ gas: EnergyData # Required
+
+# AFTER (v0.3.0)
+@dataclass
+class EssentPrices:
+ electricity: EnergyData # Required
+ gas: Optional[EnergyData] # Can be None
+```
+
+## Why This Change?
+
+The Essent API currently has a technical issue where gas data is missing. This causes the integration to completely fail, leaving all sensors (both electricity and gas) unavailable. By making gas optional:
+
+- ✅ Electricity sensors continue working when gas data is missing
+- ✅ Gas sensors gracefully show "unavailable" instead of crashing
+- ✅ When API is fixed, all sensors work normally again
+
+## Required Changes to HA Integration
+
+### File: `homeassistant/components/essent/sensor.py`
+
+**Update the `native_value` property** in the `EssentSensor` class to handle None:
+
+```python
+@property
+def native_value(self) -> float | None:
+ """Return the sensor value."""
+ # Get the appropriate energy data based on sensor type
+ energy_data = (
+ self.coordinator.data.electricity
+ if self._energy_type == "electricity"
+ else self.coordinator.data.gas
+ )
+
+ # Return None if this energy type is unavailable
+ if energy_data is None:
+ return None
+
+ # Use the value function to extract the specific metric
+ return self._value_fn(energy_data)
+```
+
+**Alternative approach** - Check during sensor setup (if you want to skip creating sensors):
+
+```python
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up Essent sensor platform."""
+ coordinator: EssentDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+
+ entities: list[EssentSensor] = []
+
+ # Always create electricity sensors
+ for sensor_description in ELECTRICITY_SENSORS:
+ entities.append(
+ EssentSensor(coordinator, sensor_description, "electricity")
+ )
+
+ # Only create gas sensors if gas data is available
+ if coordinator.data.gas is not None:
+ for sensor_description in GAS_SENSORS:
+ entities.append(
+ EssentSensor(coordinator, sensor_description, "gas")
+ )
+
+ async_add_entities(entities)
+```
+
+## Testing the Changes
+
+### Test Case 1: Gas Missing (Current API State)
+- **Expected**: Electricity sensors show values, gas sensors show "unavailable"
+- **Library returns**: `EssentPrices(electricity=EnergyData(...), gas=None)`
+
+### Test Case 2: Both Available (Normal Operation)
+- **Expected**: All sensors show values
+- **Library returns**: `EssentPrices(electricity=EnergyData(...), gas=EnergyData(...))`
+
+### Test Case 3: Both Missing
+- **Expected**: Integration fails with error (rare edge case)
+- **Library raises**: `EssentDataError("Response missing both electricity and gas data")`
+
+## Migration Steps
+
+1. Update `manifest.json` dependency:
+ ```json
+ "requirements": ["essent-dynamic-pricing==0.3.0"]
+ ```
+
+2. Update `sensor.py` with null-check logic (shown above)
+
+3. Test with current API (gas=None scenario)
+
+4. Submit PR to home-assistant/core with:
+ - Updated manifest.json
+ - Updated sensor.py
+ - Note in PR description about handling API outages
+
+## Rollback Plan
+
+If issues arise, revert to v0.2.7 and accept that the integration will be broken when gas data is missing from the API. \ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..207234a
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 Home Assistant Community
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..31f10b4
--- /dev/null
+++ b/README.md
@@ -0,0 +1,53 @@
+# Essent dynamic pricing client
+
+Async client for Essent's public dynamic price API, returning normalized electricity
+and gas tariffs ready for Home Assistant or other consumers. Tariff start/end values
+are returned as timezone-aware datetimes in the Europe/Amsterdam timezone.
+
+## Usage
+
+```python
+import asyncio
+from aiohttp import ClientSession
+from essent_dynamic_pricing import EssentClient
+
+async def main():
+ async with ClientSession() as session:
+ client = EssentClient(session=session)
+ data = await client.async_get_prices()
+
+ # Electricity data is always available
+ print(f"Electricity: {data.electricity.min_price} - {data.electricity.max_price} €/{data.electricity.unit}")
+
+ # Gas data may be None if unavailable from API
+ if data.gas:
+ print(f"Gas: {data.gas.min_price} - {data.gas.max_price} €/{data.gas.unit}")
+ else:
+ print("Gas data not available")
+
+asyncio.run(main())
+```
+
+## Breaking Changes in v0.3.0
+
+The `gas` field in `EssentPrices` is now `Optional[EnergyData]` instead of required. This handles cases where the Essent API temporarily doesn't provide gas data. Electricity data is still required.
+
+**Before (v0.2.x):**
+```python
+data = await client.async_get_prices()
+print(data.gas.min_price) # Always worked
+```
+
+**After (v0.3.0):**
+```python
+data = await client.async_get_prices()
+if data.gas:
+ print(data.gas.min_price) # Check for None first
+```
+
+## Development / tests
+
+1. Install dev deps (adds pytest and pytest-asyncio):
+ `pip install -e .[test]`
+2. Run tests:
+ `pytest`
diff --git a/debian/changelog b/debian/changelog
new file mode 100644
index 0000000..b96e0ef
--- /dev/null
+++ b/debian/changelog
@@ -0,0 +1,5 @@
+python-essent-dynamic-pricing (0.3.1-1) unstable; urgency=medium
+
+ * Initial release. (Closes: #1130075)
+
+ -- Edward Betts <edward@4angle.com> Sun, 08 Mar 2026 09:47:03 +0000
diff --git a/debian/control b/debian/control
new file mode 100644
index 0000000..81a68e6
--- /dev/null
+++ b/debian/control
@@ -0,0 +1,40 @@
+Source: python-essent-dynamic-pricing
+Maintainer: Home Assistant Team <team+homeassistant@tracker.debian.org>
+Uploaders:
+ Edward Betts <edward@4angle.com>,
+Section: python
+Build-Depends:
+ debhelper-compat (= 13),
+ dh-sequence-python3,
+ pybuild-plugin-pyproject,
+ python3-all,
+ python3-hatchling,
+Build-Depends-Indep:
+ python3-aiohttp <!nocheck>,
+ python3-mashumaro <!nocheck>,
+ python3-pytest <!nocheck>,
+ python3-pytest-asyncio <!nocheck>,
+Standards-Version: 4.7.3
+Homepage: https://github.com/jaapp/py-essent-dynamic-pricing
+Vcs-Browser: https://salsa.debian.org/homeassistant-team/deps/python-essent-dynamic-pricing
+Vcs-Git: https://salsa.debian.org/homeassistant-team/deps/python-essent-dynamic-pricing.git
+Testsuite: autopkgtest-pkg-pybuild
+
+Package: python3-essent-dynamic-pricing
+Architecture: all
+Depends:
+ ${misc:Depends},
+ ${python3:Depends},
+Description: Client for Essent dynamic energy price data
+ This library retrieves dynamic electricity and gas tariff data from Essent's
+ public pricing API and presents it in a normalized form for other software to
+ consume.
+ .
+ It provides electricity prices and, when available from the service, gas
+ prices. Returned tariff periods include start and end times as timezone-aware
+ datetimes in the Europe/Amsterdam timezone, which helps preserve the timing of
+ tariff changes.
+ .
+ The package talks to Essent's public dynamic price API and converts the
+ response into structured price data, including values such as minimum and
+ maximum prices and the unit used for each energy type.
diff --git a/debian/copyright b/debian/copyright
new file mode 100644
index 0000000..3766618
--- /dev/null
+++ b/debian/copyright
@@ -0,0 +1,35 @@
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+Upstream-Name: essent-dynamic-pricing
+Upstream-Contact: Jaap Pieroen <github@pieroen.nl>
+Source: https://github.com/jaapp/py-essent-dynamic-pricing
+
+Files: *
+Copyright: 2025 Home Assistant Community
+License: MIT
+
+Files: debian/*
+Copyright: 2026 Edward Betts <edward@4angle.com>
+License: MIT
+
+
+License: MIT
+ Permission is hereby granted, free of charge, to any person
+ obtaining a copy of this software and associated documentation
+ files (the "Software"), to deal in the Software without
+ restriction, including without limitation the rights to use,
+ copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the
+ Software is furnished to do so, subject to the following
+ conditions:
+ .
+ The above copyright notice and this permission notice shall be
+ included in all copies or substantial portions of the Software.
+ .
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ OTHER DEALINGS IN THE SOFTWARE.
diff --git a/debian/docs b/debian/docs
new file mode 100644
index 0000000..972b40d
--- /dev/null
+++ b/debian/docs
@@ -0,0 +1,2 @@
+HA_INTEGRATION_CHANGES.md
+README.md
diff --git a/debian/rules b/debian/rules
new file mode 100755
index 0000000..bc8aecb
--- /dev/null
+++ b/debian/rules
@@ -0,0 +1,3 @@
+#!/usr/bin/make -f
+%:
+ dh $@ --buildsystem=pybuild
diff --git a/debian/salsa-ci.yml b/debian/salsa-ci.yml
new file mode 100644
index 0000000..84d19ed
--- /dev/null
+++ b/debian/salsa-ci.yml
@@ -0,0 +1,3 @@
+---
+include:
+ - https://salsa.debian.org/salsa-ci-team/pipeline/raw/master/recipes/debian.yml \ No newline at end of file
diff --git a/debian/source/format b/debian/source/format
new file mode 100644
index 0000000..46ebe02
--- /dev/null
+++ b/debian/source/format
@@ -0,0 +1 @@
+3.0 (quilt) \ No newline at end of file
diff --git a/debian/upstream/metadata b/debian/upstream/metadata
new file mode 100644
index 0000000..d049362
--- /dev/null
+++ b/debian/upstream/metadata
@@ -0,0 +1,4 @@
+Bug-Database: https://github.com/jaapp/py-essent-dynamic-pricing/issues
+Bug-Submit: https://github.com/jaapp/py-essent-dynamic-pricing/issues/new
+Repository: https://github.com/jaapp/py-essent-dynamic-pricing.git
+Repository-Browse: https://github.com/jaapp/py-essent-dynamic-pricing \ No newline at end of file
diff --git a/debian/watch b/debian/watch
new file mode 100644
index 0000000..a93b0c8
--- /dev/null
+++ b/debian/watch
@@ -0,0 +1,5 @@
+Version: 5
+
+Template: Github
+Owner: jaapp
+Project: py-essent-dynamic-pricing \ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..454f091
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,34 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "essent-dynamic-pricing"
+version = "0.3.1"
+description = "Async client for Essent dynamic energy prices"
+readme = "README.md"
+license = {text = "MIT"}
+requires-python = ">=3.13"
+authors = [
+ {name = "Home Assistant Community"},
+]
+dependencies = [
+ "aiohttp>=3.9",
+ "mashumaro>=3.17",
+]
+
+[project.optional-dependencies]
+test = [
+ "pytest>=8.2",
+ "pytest-asyncio>=0.24",
+]
+
+[project.urls]
+Homepage = "https://github.com/jaapp/py-essent-dynamic-pricing"
+Repository = "https://github.com/jaapp/py-essent-dynamic-pricing"
+
+[tool.hatch.build.targets.wheel]
+force-include = {"src/essent_dynamic_pricing/py.typed" = "essent_dynamic_pricing/py.typed"}
+
+[tool.pytest.ini_options]
+asyncio_mode = "auto"
diff --git a/src/essent_dynamic_pricing/__init__.py b/src/essent_dynamic_pricing/__init__.py
new file mode 100644
index 0000000..1ac81cb
--- /dev/null
+++ b/src/essent_dynamic_pricing/__init__.py
@@ -0,0 +1,21 @@
+"""Async client for Essent dynamic energy prices."""
+
+from .client import EssentClient
+from .exceptions import (
+ EssentConnectionError,
+ EssentDataError,
+ EssentError,
+ EssentResponseError,
+)
+from .models import EssentPrices, EnergyData, Tariff
+
+__all__ = [
+ "EssentClient",
+ "EssentConnectionError",
+ "EssentPrices",
+ "EnergyData",
+ "Tariff",
+ "EssentDataError",
+ "EssentError",
+ "EssentResponseError",
+]
diff --git a/src/essent_dynamic_pricing/client.py b/src/essent_dynamic_pricing/client.py
new file mode 100644
index 0000000..5d7b3cd
--- /dev/null
+++ b/src/essent_dynamic_pricing/client.py
@@ -0,0 +1,204 @@
+"""Async client for Essent dynamic pricing."""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from http import HTTPStatus
+from zoneinfo import ZoneInfo
+
+from aiohttp import ClientError, ClientResponse, ClientSession, ClientTimeout
+from mashumaro.exceptions import ExtraKeysError, InvalidFieldValue, MissingField
+
+from .exceptions import EssentConnectionError, EssentDataError, EssentResponseError
+from .models import (
+ EnergyBlock,
+ EnergyData,
+ EssentPrices,
+ PriceResponse,
+ PriceDay,
+ Tariff,
+)
+
+API_ENDPOINT = "https://www.essent.nl/api/public/dynamicpricing/dynamic-prices/v1"
+CLIENT_TIMEOUT = ClientTimeout(total=10)
+ESSENT_TIME_ZONE = ZoneInfo("Europe/Amsterdam")
+
+
+def _normalize_tariff_datetime(value: datetime | None) -> datetime | None:
+ """Normalize tariff datetimes to the Essent timezone."""
+ if value is None:
+ return None
+ if value.tzinfo is None:
+ return value.replace(tzinfo=ESSENT_TIME_ZONE)
+ return value.astimezone(ESSENT_TIME_ZONE)
+
+
+def _tariff_sort_key(tariff: Tariff) -> datetime:
+ """Sort key for tariffs based on start time."""
+ return tariff.start or datetime.max.replace(tzinfo=timezone.utc)
+
+
+def _prepare_tariffs(tariffs: list[Tariff], energy_type: str) -> list[Tariff]:
+ """Normalize tariffs to timezone-aware datetimes and sort them."""
+ prepared: list[Tariff] = []
+ for tariff in tariffs:
+ if tariff.start is None or tariff.end is None:
+ raise EssentDataError(f"Tariff missing start or end for {energy_type}")
+ prepared.append(
+ Tariff(
+ start=_normalize_tariff_datetime(tariff.start),
+ end=_normalize_tariff_datetime(tariff.end),
+ total_amount=tariff.total_amount,
+ total_amount_ex=tariff.total_amount_ex,
+ total_amount_vat=tariff.total_amount_vat,
+ groups=list(tariff.groups),
+ )
+ )
+ return sorted(prepared, key=_tariff_sort_key)
+
+
+def _normalize_unit(unit: str) -> str:
+ """Normalize unit strings to human-friendly values."""
+ unit_normalized = unit.replace("³", "3").lower()
+ if unit_normalized == "kwh":
+ return "kWh"
+ if unit_normalized in {"m3", "m^3"}:
+ return "m³"
+ return unit
+
+
+class EssentClient:
+ """Client for fetching Essent dynamic pricing data."""
+
+ def __init__(
+ self,
+ session: ClientSession,
+ *,
+ endpoint: str = API_ENDPOINT,
+ timeout: ClientTimeout = CLIENT_TIMEOUT,
+ ) -> None:
+ """Initialize the client."""
+ self._session = session
+ self._endpoint = endpoint
+ self._timeout = timeout
+
+ async def async_get_prices(self) -> EssentPrices:
+ """Fetch and normalize Essent dynamic pricing data."""
+ response = await self._request()
+ body = await response.text()
+
+ if response.status != HTTPStatus.OK:
+ raise EssentResponseError(
+ f"Unexpected status {response.status} from Essent API: {body}"
+ )
+
+ try:
+ price_response = PriceResponse.from_dict(await response.json())
+ except (MissingField, InvalidFieldValue, ExtraKeysError) as err:
+ raise EssentDataError("Invalid data structure for current prices") from err
+ except ValueError as err:
+ raise EssentResponseError("Invalid JSON received from Essent API") from err
+
+ if not price_response.prices:
+ raise EssentDataError("No price data available")
+
+ today, tomorrow = self._select_days(price_response.prices)
+
+ if today.electricity is None and today.gas is None:
+ raise EssentDataError("Response missing both electricity and gas data")
+
+ electricity_data = None
+ if today.electricity is not None:
+ electricity_data = self._normalize_energy_block(
+ today.electricity,
+ "electricity",
+ tomorrow.electricity if tomorrow else None,
+ )
+
+ gas_data = None
+ if today.gas is not None:
+ gas_data = self._normalize_energy_block(
+ today.gas,
+ "gas",
+ tomorrow.gas if tomorrow else None,
+ )
+
+ if electricity_data is None:
+ raise EssentDataError("Response missing electricity data")
+
+ return EssentPrices(
+ electricity=electricity_data,
+ gas=gas_data,
+ )
+
+ async def _request(self) -> ClientResponse:
+ """Perform the HTTP request."""
+ try:
+ return await self._session.get(
+ self._endpoint,
+ timeout=self._timeout,
+ headers={"Accept": "application/json"},
+ )
+ except ClientError as err:
+ raise EssentConnectionError(f"Error communicating with API: {err}") from err
+
+ @staticmethod
+ def _select_days(
+ prices: list[PriceDay],
+ ) -> tuple[PriceDay, PriceDay | None]:
+ """Find entries for today and tomorrow from the price list."""
+ if not prices:
+ raise EssentDataError("No price data available")
+
+ current_date = datetime.now(timezone.utc).astimezone().date().isoformat()
+ today_index = 0
+ for idx, price in enumerate(prices):
+ if price.date == current_date:
+ today_index = idx
+ break
+
+ today = prices[today_index]
+ tomorrow: PriceDay | None = None
+ if today_index + 1 < len(prices):
+ tomorrow = prices[today_index + 1]
+
+ return today, tomorrow
+
+ def _normalize_energy_block(
+ self,
+ data: EnergyBlock | None,
+ energy_type: str,
+ tomorrow: EnergyBlock | None,
+ ) -> EnergyData:
+ """Normalize the energy block into the client format."""
+ if data is None:
+ raise EssentDataError(f"No {energy_type} data provided")
+
+ tariffs_today = _prepare_tariffs(data.tariffs, energy_type)
+ if not tariffs_today:
+ raise EssentDataError(f"No tariffs found for {energy_type}")
+
+ tariffs_tomorrow = (
+ _prepare_tariffs(tomorrow.tariffs, energy_type) if tomorrow else []
+ )
+ unit_raw = (data.unit_of_measurement or data.unit or "").strip()
+
+ amounts = [
+ float(total)
+ for tariff in tariffs_today
+ if (total := tariff.total_amount) is not None
+ ]
+ if not amounts:
+ raise EssentDataError(f"No usable tariff values for {energy_type}")
+
+ if not unit_raw:
+ raise EssentDataError(f"No unit provided for {energy_type}")
+
+ return EnergyData(
+ tariffs=tariffs_today,
+ tariffs_tomorrow=tariffs_tomorrow,
+ unit=_normalize_unit(unit_raw),
+ min_price=min(amounts),
+ avg_price=sum(amounts) / len(amounts),
+ max_price=max(amounts),
+ )
diff --git a/src/essent_dynamic_pricing/exceptions.py b/src/essent_dynamic_pricing/exceptions.py
new file mode 100644
index 0000000..08887ae
--- /dev/null
+++ b/src/essent_dynamic_pricing/exceptions.py
@@ -0,0 +1,16 @@
+"""Exceptions for the Essent dynamic pricing client."""
+
+class EssentError(Exception):
+ """Base exception for Essent client errors."""
+
+
+class EssentConnectionError(EssentError):
+ """Raised when communication with the API fails."""
+
+
+class EssentResponseError(EssentError):
+ """Raised when the API returns an unexpected response."""
+
+
+class EssentDataError(EssentError):
+ """Raised when the response data is missing or invalid."""
diff --git a/src/essent_dynamic_pricing/models.py b/src/essent_dynamic_pricing/models.py
new file mode 100644
index 0000000..27ba39f
--- /dev/null
+++ b/src/essent_dynamic_pricing/models.py
@@ -0,0 +1,84 @@
+"""Models used by the Essent dynamic client."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from datetime import datetime
+from typing import Any, List, Optional
+
+from mashumaro.mixins.dict import DataClassDictMixin
+
+
+@dataclass
+class Tariff(DataClassDictMixin):
+ """A single tariff entry."""
+
+ start: Optional[datetime] = field(default=None, metadata={"alias": "startDateTime"})
+ end: Optional[datetime] = field(default=None, metadata={"alias": "endDateTime"})
+ total_amount: Optional[float] = field(default=None, metadata={"alias": "totalAmount"})
+ total_amount_ex: Optional[float] = field(default=None, metadata={"alias": "totalAmountEx"})
+ total_amount_vat: Optional[float] = field(default=None, metadata={"alias": "totalAmountVat"})
+ groups: List[dict[str, Any]] = field(default_factory=list)
+
+ class Config:
+ serialize_by_alias = True
+
+
+@dataclass
+class EnergyBlock(DataClassDictMixin):
+ """Raw energy block from the API."""
+
+ tariffs: List[Tariff] = field(default_factory=list)
+ unit: Optional[str] = None
+ unit_of_measurement: Optional[str] = field(default=None, metadata={"alias": "unitOfMeasurement"})
+
+ class Config:
+ serialize_by_alias = True
+
+
+@dataclass
+class PriceDay(DataClassDictMixin):
+ """Prices for a day."""
+
+ date: str
+ electricity: Optional[EnergyBlock] = None
+ gas: Optional[EnergyBlock] = None
+
+ class Config:
+ serialize_by_alias = True
+
+
+@dataclass
+class PriceResponse(DataClassDictMixin):
+ """Top level API response."""
+
+ prices: List[PriceDay]
+
+ class Config:
+ serialize_by_alias = True
+
+
+@dataclass
+class EnergyData(DataClassDictMixin):
+ """Normalized energy data."""
+
+ tariffs: List[Tariff]
+ tariffs_tomorrow: List[Tariff]
+ unit: str
+ min_price: float
+ avg_price: float
+ max_price: float
+
+ class Config:
+ serialize_by_alias = True
+
+
+@dataclass
+class EssentPrices(DataClassDictMixin):
+ """Normalized Essent prices for both energy types."""
+
+ electricity: EnergyData
+ gas: Optional[EnergyData] = None
+
+ class Config:
+ serialize_by_alias = True
diff --git a/src/essent_dynamic_pricing/py.typed b/src/essent_dynamic_pricing/py.typed
new file mode 100644
index 0000000..7632ecf
--- /dev/null
+++ b/src/essent_dynamic_pricing/py.typed
@@ -0,0 +1 @@
+# Marker file for PEP 561
diff --git a/tests/test_client.py b/tests/test_client.py
new file mode 100644
index 0000000..1711b28
--- /dev/null
+++ b/tests/test_client.py
@@ -0,0 +1,568 @@
+"""Tests for the Essent client."""
+
+from __future__ import annotations
+
+import asyncio
+from typing import Any
+from zoneinfo import ZoneInfo
+
+import pytest
+from datetime import datetime, timedelta, timezone
+from essent_dynamic_pricing.client import _normalize_unit
+from essent_dynamic_pricing.models import PriceResponse
+
+from aiohttp import ClientError
+
+from essent_dynamic_pricing import (
+ EssentClient,
+ EssentConnectionError,
+ EssentDataError,
+ EssentResponseError,
+)
+
+
+class _MockResponse:
+ """Simple mock response."""
+
+ def __init__(self, status: int, body: Any) -> None:
+ """Initialize the response."""
+ self.status = status
+ self._body = body
+
+ async def text(self) -> str:
+ """Return the raw body."""
+ if isinstance(self._body, str):
+ return self._body
+ return repr(self._body)
+
+ async def json(self) -> Any:
+ """Return the JSON body."""
+ if isinstance(self._body, Exception):
+ raise self._body
+ return self._body
+
+
+class _MockSession:
+ """Mock session returning fixed responses."""
+
+ def __init__(self, response: _MockResponse | Exception) -> None:
+ self._response = response
+
+ async def get(self, *args: Any, **kwargs: Any) -> _MockResponse:
+ """Return the pre-seeded response or raise."""
+ if isinstance(self._response, Exception):
+ raise self._response
+ return self._response
+
+
+def _build_prices() -> dict[str, Any]:
+ """Build a sample prices payload."""
+ return {
+ "prices": [
+ {
+ "date": "2025-11-16",
+ "electricity": {
+ "unitOfMeasurement": "kWh",
+ "tariffs": [
+ {
+ "startDateTime": "2025-11-16T00:00:00",
+ "endDateTime": "2025-11-16T01:00:00",
+ "totalAmount": 0.2,
+ },
+ {
+ "startDateTime": "2025-11-16T01:00:00",
+ "endDateTime": "2025-11-16T02:00:00",
+ "totalAmount": 0.25,
+ },
+ ],
+ },
+ "gas": {
+ "unit": "m3",
+ "tariffs": [
+ {
+ "startDateTime": "2025-11-16T00:00:00",
+ "endDateTime": "2025-11-16T01:00:00",
+ "totalAmount": 0.8,
+ },
+ {
+ "startDateTime": "2025-11-16T01:00:00",
+ "endDateTime": "2025-11-16T02:00:00",
+ "totalAmount": 0.82,
+ },
+ ],
+ },
+ },
+ {
+ "date": "2025-11-17",
+ "electricity": {
+ "unitOfMeasurement": "kWh",
+ "tariffs": [
+ {
+ "startDateTime": "2025-11-17T00:00:00",
+ "endDateTime": "2025-11-17T01:00:00",
+ "totalAmount": 0.22,
+ },
+ ],
+ },
+ "gas": {
+ "unit": "m3",
+ "tariffs": [
+ {
+ "startDateTime": "2025-11-17T00:00:00",
+ "endDateTime": "2025-11-17T01:00:00",
+ "totalAmount": 0.85,
+ },
+ ],
+ },
+ },
+ ]
+ }
+
+
+@pytest.mark.asyncio
+async def test_fetch_success(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Test a successful fetch."""
+ response = _MockResponse(status=200, body=_build_prices())
+ client = EssentClient(_MockSession(response))
+
+ data = await client.async_get_prices()
+
+ assert data.electricity.min_price == 0.2
+ assert data.electricity.max_price == 0.25
+ assert data.gas.unit == "m³"
+ assert data.electricity.tariffs[0].start == datetime(
+ 2025, 11, 16, 0, 0, tzinfo=ZoneInfo("Europe/Amsterdam")
+ )
+ assert data.gas.tariffs[0].start.tzinfo == ZoneInfo("Europe/Amsterdam")
+
+
+@pytest.mark.asyncio
+async def test_non_ok_status() -> None:
+ """Test non-200 handling."""
+ response = _MockResponse(status=500, body="error")
+ client = EssentClient(_MockSession(response))
+
+ with pytest.raises(EssentResponseError):
+ await client.async_get_prices()
+
+
+@pytest.mark.asyncio
+async def test_invalid_json() -> None:
+ """Test invalid JSON handling."""
+ response = _MockResponse(status=200, body=ValueError("boom"))
+ client = EssentClient(_MockSession(response))
+
+ with pytest.raises(EssentResponseError):
+ await client.async_get_prices()
+
+
+@pytest.mark.asyncio
+async def test_missing_prices() -> None:
+ """Test empty prices payload."""
+ response = _MockResponse(status=200, body={"prices": []})
+ client = EssentClient(_MockSession(response))
+
+ with pytest.raises(EssentDataError):
+ await client.async_get_prices()
+
+
+@pytest.mark.asyncio
+async def test_connection_error(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Test connection errors are raised properly."""
+ client = EssentClient(_MockSession(ClientError("boom")))
+
+ with pytest.raises(EssentConnectionError):
+ await client.async_get_prices()
+
+
+@pytest.mark.asyncio
+async def test_missing_electricity_block() -> None:
+ """Response missing electricity should error."""
+ today = datetime.now(timezone.utc).date().isoformat()
+ response = _MockResponse(
+ status=200,
+ body={"prices": [{"date": today, "electricity": None}]},
+ )
+ client = EssentClient(_MockSession(response))
+
+ with pytest.raises(
+ EssentDataError, match="Response missing both electricity and gas data"
+ ):
+ await client.async_get_prices()
+
+
+@pytest.mark.asyncio
+async def test_selects_today_entry() -> None:
+ """When today's date is present it should be preferred."""
+ today = datetime.now(timezone.utc).date().isoformat()
+ body = {
+ "prices": [
+ {
+ "date": today,
+ "electricity": {
+ "tariffs": [
+ {
+ "startDateTime": "2025-11-16T00:00:00",
+ "endDateTime": "2025-11-16T01:00:00",
+ "totalAmount": 0.3,
+ }
+ ],
+ "unitOfMeasurement": "kWh",
+ },
+ "gas": {
+ "tariffs": [
+ {
+ "startDateTime": "2025-11-16T00:00:00",
+ "endDateTime": "2025-11-16T01:00:00",
+ "totalAmount": 0.9,
+ }
+ ],
+ "unit": "m3",
+ },
+ },
+ {
+ "date": "2025-11-17",
+ "electricity": {
+ "tariffs": [
+ {
+ "startDateTime": "2025-11-17T00:00:00",
+ "endDateTime": "2025-11-17T01:00:00",
+ "totalAmount": 0.4,
+ }
+ ],
+ "unitOfMeasurement": "kWh",
+ },
+ "gas": {
+ "tariffs": [
+ {
+ "startDateTime": "2025-11-17T00:00:00",
+ "endDateTime": "2025-11-17T01:00:00",
+ "totalAmount": 1.0,
+ }
+ ],
+ "unit": "m3",
+ },
+ },
+ ]
+ }
+ client = EssentClient(_MockSession(_MockResponse(status=200, body=body)))
+
+ data = await client.async_get_prices()
+
+ assert data.electricity.min_price == 0.3
+ assert data.electricity.tariffs_tomorrow[0].total_amount == 0.4
+ assert data.gas.tariffs_tomorrow[0].total_amount == 1.0
+
+
+@pytest.mark.asyncio
+async def test_missing_gas_for_future_day() -> None:
+ """Missing gas for a future day should not block electricity data."""
+ today = datetime.now(timezone.utc).date()
+ tomorrow = today + timedelta(days=1)
+ response = _MockResponse(
+ status=200,
+ body={
+ "prices": [
+ {
+ "date": today.isoformat(),
+ "electricity": {
+ "tariffs": [
+ {
+ "startDateTime": "2025-11-16T00:00:00",
+ "endDateTime": "2025-11-16T01:00:00",
+ "totalAmount": 0.3,
+ }
+ ],
+ "unitOfMeasurement": "kWh",
+ },
+ "gas": {
+ "tariffs": [
+ {
+ "startDateTime": "2025-11-16T00:00:00",
+ "endDateTime": "2025-11-16T01:00:00",
+ "totalAmount": 0.9,
+ }
+ ],
+ "unit": "m3",
+ },
+ },
+ {
+ "date": tomorrow.isoformat(),
+ "electricity": {
+ "tariffs": [
+ {
+ "startDateTime": "2025-11-17T00:00:00",
+ "endDateTime": "2025-11-17T01:00:00",
+ "totalAmount": 0.4,
+ }
+ ],
+ "unitOfMeasurement": "kWh",
+ },
+ },
+ ]
+ },
+ )
+ client = EssentClient(_MockSession(response))
+
+ data = await client.async_get_prices()
+
+ assert data.electricity.tariffs_tomorrow[0].total_amount == 0.4
+ assert data.gas.tariffs_tomorrow == []
+
+
+@pytest.mark.asyncio
+async def test_invalid_today_structure() -> None:
+ """Invalid today structure should raise."""
+ today = datetime.now(timezone.utc).date().isoformat()
+ response = _MockResponse(
+ status=200,
+ body={
+ "prices": [
+ {"date": "not-today"},
+ {"date": today, "electricity": "bad", "gas": "bad"},
+ ]
+ },
+ )
+ client = EssentClient(_MockSession(response))
+
+ with pytest.raises(EssentDataError, match="Invalid data structure"):
+ await client.async_get_prices()
+
+
+@pytest.mark.asyncio
+async def test_no_tariffs() -> None:
+ """No tariffs should raise a data error."""
+ today = datetime.now(timezone.utc).date().isoformat()
+ response = _MockResponse(
+ status=200,
+ body={
+ "prices": [
+ {
+ "date": today,
+ "electricity": {"tariffs": []},
+ "gas": {"tariffs": [{"totalAmount": 0.8}], "unit": "m3"},
+ }
+ ]
+ },
+ )
+ client = EssentClient(_MockSession(response))
+
+ with pytest.raises(EssentDataError, match="No tariffs found for electricity"):
+ await client.async_get_prices()
+
+
+@pytest.mark.asyncio
+async def test_no_amounts() -> None:
+ """Tariffs without totals should raise a data error."""
+ today = datetime.now(timezone.utc).date().isoformat()
+ response = _MockResponse(
+ status=200,
+ body={
+ "prices": [
+ {
+ "date": today,
+ "electricity": {
+ "tariffs": [
+ {
+ "startDateTime": "2025-11-16T00:00:00",
+ "endDateTime": "2025-11-16T01:00:00",
+ }
+ ]
+ },
+ "gas": {"tariffs": [{"totalAmount": 0.8}], "unit": "m3"},
+ }
+ ]
+ },
+ )
+ client = EssentClient(_MockSession(response))
+
+ with pytest.raises(EssentDataError, match="No usable tariff values for electricity"):
+ await client.async_get_prices()
+
+
+@pytest.mark.asyncio
+async def test_no_unit() -> None:
+ """Missing unit should raise a data error."""
+ today = datetime.now(timezone.utc).date().isoformat()
+ response = _MockResponse(
+ status=200,
+ body={
+ "prices": [
+ {
+ "date": today,
+ "electricity": {
+ "tariffs": [
+ {
+ "startDateTime": "2025-11-16T00:00:00",
+ "endDateTime": "2025-11-16T01:00:00",
+ "totalAmount": 0.25,
+ }
+ ]
+ },
+ "gas": {
+ "tariffs": [
+ {
+ "startDateTime": "2025-11-16T00:00:00",
+ "endDateTime": "2025-11-16T01:00:00",
+ "totalAmount": 0.82,
+ }
+ ],
+ "unit": "m³",
+ },
+ }
+ ]
+ },
+ )
+ client = EssentClient(_MockSession(response))
+
+ with pytest.raises(EssentDataError, match="No unit provided for electricity"):
+ await client.async_get_prices()
+
+
+@pytest.mark.asyncio
+async def test_missing_tariff_bounds() -> None:
+ """Missing start or end should raise a data error."""
+ today = datetime.now(timezone.utc).date().isoformat()
+ response = _MockResponse(
+ status=200,
+ body={
+ "prices": [
+ {
+ "date": today,
+ "electricity": {
+ "tariffs": [{"endDateTime": "2025-11-16T01:00:00"}],
+ "unit": "kWh",
+ },
+ "gas": {
+ "tariffs": [
+ {
+ "startDateTime": "2025-11-16T00:00:00",
+ "totalAmount": 0.8,
+ }
+ ],
+ "unit": "m3",
+ },
+ }
+ ]
+ },
+ )
+ client = EssentClient(_MockSession(response))
+
+ with pytest.raises(EssentDataError, match="Tariff missing start or end for electricity"):
+ await client.async_get_prices()
+
+
+def test_normalize_unit_passthrough() -> None:
+ """Unknown unit should be returned unchanged."""
+ assert _normalize_unit("unknown") == "unknown"
+
+
+def test_select_days_prefers_today_and_tomorrow() -> None:
+ """_select_days should return today entry and next as tomorrow when available."""
+ today = datetime.now(timezone.utc).date().isoformat()
+ resp = PriceResponse.from_dict(
+ {
+ "prices": [
+ {
+ "date": today,
+ "electricity": {"unit": "kWh", "tariffs": [{"totalAmount": 1}]},
+ "gas": {"unit": "m3", "tariffs": [{"totalAmount": 1}]},
+ },
+ {
+ "date": "next",
+ "electricity": {"unit": "kWh", "tariffs": [{"totalAmount": 2}]},
+ "gas": {"unit": "m3", "tariffs": [{"totalAmount": 2}]},
+ },
+ ]
+ }
+ )
+
+ selected_today, selected_tomorrow = EssentClient._select_days(resp.prices)
+
+ assert selected_today.electricity.tariffs[0].total_amount == 1
+ assert selected_tomorrow is not None
+ assert selected_tomorrow.electricity.tariffs[0].total_amount == 2
+
+
+def test_select_days_invalid_structure_raises() -> None:
+ """Empty prices should raise a data error."""
+ with pytest.raises(EssentDataError, match="No price data available"):
+ EssentClient._select_days([])
+
+
+@pytest.mark.asyncio
+async def test_electricity_only_success() -> None:
+ """Missing gas data should succeed if electricity is present."""
+ today = datetime.now(timezone.utc).date().isoformat()
+ response = _MockResponse(
+ status=200,
+ body={
+ "prices": [
+ {
+ "date": today,
+ "electricity": {
+ "tariffs": [
+ {
+ "startDateTime": "2025-11-16T00:00:00",
+ "endDateTime": "2025-11-16T01:00:00",
+ "totalAmount": 0.3,
+ }
+ ],
+ "unitOfMeasurement": "kWh",
+ },
+ }
+ ]
+ },
+ )
+ client = EssentClient(_MockSession(response))
+
+ data = await client.async_get_prices()
+
+ assert data.electricity.min_price == 0.3
+ assert data.gas is None
+
+
+@pytest.mark.asyncio
+async def test_gas_only_fails() -> None:
+ """Missing electricity data should raise error even if gas is present."""
+ today = datetime.now(timezone.utc).date().isoformat()
+ response = _MockResponse(
+ status=200,
+ body={
+ "prices": [
+ {
+ "date": today,
+ "gas": {
+ "tariffs": [
+ {
+ "startDateTime": "2025-11-16T00:00:00",
+ "endDateTime": "2025-11-16T01:00:00",
+ "totalAmount": 0.9,
+ }
+ ],
+ "unit": "m3",
+ },
+ }
+ ]
+ },
+ )
+ client = EssentClient(_MockSession(response))
+
+ with pytest.raises(EssentDataError, match="Response missing electricity data"):
+ await client.async_get_prices()
+
+
+@pytest.mark.asyncio
+async def test_both_missing_fails() -> None:
+ """Missing both energy types should raise error."""
+ today = datetime.now(timezone.utc).date().isoformat()
+ response = _MockResponse(
+ status=200,
+ body={"prices": [{"date": today}]},
+ )
+ client = EssentClient(_MockSession(response))
+
+ with pytest.raises(
+ EssentDataError, match="Response missing both electricity and gas data"
+ ):
+ await client.async_get_prices()