From a1ed45b3c9b5e31fcc7858f22c562efe87ef81d4 Mon Sep 17 00:00:00 2001 From: Steve Aitken Date: Sun, 14 Dec 2025 17:11:21 +0000 Subject: [PATCH 01/10] docs: update README with features and EV smart charging sensors --- README.md | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c2b034a..e71609a 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,29 @@ # Eon Next -This is a custom component for Home Assistant which integrates with the Eon Next API and gets all the meter readings from your accounts. +This is a custom component for Home Assistant which integrates with the Eon Next API to retrieve meter readings and smart charging schedules from your Eon Next accounts. +## Features + +### Meter Readings A sensor will be created for each meter showing: -- The latest reading -- The date the latest reading was taken +- **Latest Reading**: The most recent consumption value +- **Reading Date**: When the latest reading was taken + +For electric meters, readings are displayed in kWh. For gas meters, readings are in m³. + +An additional sensor is created for gas meters showing the latest reading converted to kWh using standard calorific values. + +### Smart Charging (EV Chargers) +For each connected smart charger, the following sensors are created: -For electric meters the readings are in kWh. For gas meter the readings are in m³. +- **Next Charge Start**: Scheduled start time of the next charging slot +- **Next Charge End**: Scheduled end time of the next charging slot +- **Next Charge Start 2**: Scheduled start time of the second charging slot +- **Next Charge End 2**: Scheduled end time of the second charging slot +- **Smart Charging Schedule**: Full schedule data for the charger -An additional sensor is created for gas meters showing the latest reading in kWh. +These sensors allow you to monitor and automate your EV charging based on Eon Next's smart charging recommendations. ## Installation From 53997d4f3711f3f5f75d81fa797ce61c84c87bab Mon Sep 17 00:00:00 2001 From: Steve Aitken Date: Sun, 14 Dec 2025 17:15:34 +0000 Subject: [PATCH 02/10] update to work now, and add ability to get charging schedules for car --- custom_components/eon_next/__init__.py | 4 +- custom_components/eon_next/eonnext.py | 70 ++++++++++++++- custom_components/eon_next/sensor.py | 116 ++++++++++++++++++++++++- 3 files changed, 183 insertions(+), 7 deletions(-) diff --git a/custom_components/eon_next/__init__.py b/custom_components/eon_next/__init__.py index a4c35a0..aaf2829 100755 --- a/custom_components/eon_next/__init__.py +++ b/custom_components/eon_next/__init__.py @@ -21,9 +21,7 @@ async def async_setup_entry(hass, entry): hass.data[DOMAIN][entry.entry_id] = api - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, "sensor") - ) + await hass.config_entries.async_forward_entry_setups(entry, ["sensor"]) return True diff --git a/custom_components/eon_next/eonnext.py b/custom_components/eon_next/eonnext.py index b4e692d..6bebcfe 100644 --- a/custom_components/eon_next/eonnext.py +++ b/custom_components/eon_next/eonnext.py @@ -1,10 +1,14 @@ #!/usr/bin/env python3 +import logging import aiohttp import datetime +_LOGGER = logging.getLogger(__name__) + METER_TYPE_GAS = "gas" METER_TYPE_ELECTRIC = "electricity" +METER_TYPE_EV = "ev" METER_TYPE_UNKNOWN = "unknown" @@ -92,18 +96,30 @@ async def __auth_token(self) -> str: async def _graphql_post(self, operation: str, query: str, variables: dict={}, authenticated: bool = True) -> dict: - use_headers = {} + use_headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + } if authenticated == True: use_headers['authorization'] = "JWT " + await self.__auth_token() + payload = {"operationName": operation, "variables": variables, "query": query} + _LOGGER.debug(f"GraphQL Payload: {payload}") + async with aiohttp.ClientSession() as session: async with session.post( "https://api.eonnext-kraken.energy/v1/graphql/", - json={"operationName": operation, "variables": variables, "query": query}, + json=payload, headers=use_headers ) as response: - return await response.json() + try: + json_data = await response.json() + _LOGGER.debug(f"GraphQL Response for {operation}: {json_data}") + return json_data + except Exception as e: + text = await response.text() + _LOGGER.error(f"Failed to parse JSON response. Status: {response.status}. Body: {text}") + raise e async def login_with_username_and_password(self, username: str, password: str, initialise: bool = True) -> bool: @@ -185,6 +201,7 @@ async def __init_accounts(self): account = EnergyAccount(self, account_number) await account._load_meters() + await account._load_ev_chargers() self.accounts.append(account) @@ -196,8 +213,28 @@ class EnergyAccount: def __init__(self, api: EonNext, account_number: str): self.api = api self.account_number = account_number + self.ev_chargers = [] + async def _load_ev_chargers(self): + result = await self.api._graphql_post( + "getAccountDevices", + "query getAccountDevices($accountNumber: String!) {\n devices(accountNumber: $accountNumber) {\n id\n provider\n deviceType\n status {\n current\n }\n __typename\n ... on SmartFlexVehicle {\n make\n model\n }\n ... on SmartFlexChargePoint {\n make\n model\n }\n }\n}\n", + { + "accountNumber": self.account_number + } + ) + + if self.api._json_contains_key_chain(result, ["data", "devices"]) == True: + for device in result['data']['devices']: + # We are interested in devices that are active and are either vehicles or chargers + # For now, we'll treat them all as "SmartCharging" entities + if device.get('status', {}).get('current') == 'LIVE': + name = f"{device.get('make', 'Unknown')} {device.get('model', 'Device')}" + charger = SmartCharging(self, device['id'], name) + self.ev_chargers.append(charger) + + async def _load_meters(self): result = await self.api._graphql_post( "getAccountMeterSelector", @@ -361,3 +398,30 @@ async def get_latest_reading_kwh(self) -> int: kwh = kwh / 3.6 return round(kwh) + + +class SmartCharging(EnergyMeter): + + def __init__(self, account: EnergyAccount, meter_id: str, serial: str): + super().__init__(account, meter_id, serial) + self.type = METER_TYPE_EV + self.schedule = None + + + async def _update(self): + result = await self.api._graphql_post( + "getSmartChargingSchedule", + "query getSmartChargingSchedule($deviceId: String!) {\n flexPlannedDispatches(deviceId: $deviceId) {\n start\n end\n type\n energyAddedKwh\n }\n}\n", + { + "deviceId": self.meter_id + } + ) + + if self.api._json_contains_key_chain(result, ["data", "flexPlannedDispatches"]) == True: + self.schedule = result['data']['flexPlannedDispatches'] + self.last_updated = datetime.datetime.now() + + async def get_schedule(self): + await self.update() + return self.schedule + diff --git a/custom_components/eon_next/sensor.py b/custom_components/eon_next/sensor.py index 1e2c4c5..f57012c 100755 --- a/custom_components/eon_next/sensor.py +++ b/custom_components/eon_next/sensor.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import logging +from homeassistant.util import dt as dt_util from homeassistant.components.sensor import ( SensorDeviceClass, @@ -13,7 +14,7 @@ ) from . import DOMAIN -from .eonnext import METER_TYPE_GAS, METER_TYPE_ELECTRIC +from .eonnext import METER_TYPE_GAS, METER_TYPE_ELECTRIC, METER_TYPE_EV _LOGGER = logging.getLogger(__name__) @@ -36,6 +37,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): if meter.get_type() == METER_TYPE_GAS: entities.append(LatestGasCubicMetersSensor(meter)) entities.append(LatestGasKwhSensor(meter)) + + for charger in account.ev_chargers: + entities.append(SmartChargingScheduleSensor(charger)) + entities.append(NextChargeStartSensor(charger)) + entities.append(NextChargeEndSensor(charger)) + entities.append(NextChargeStartSensor2(charger)) + entities.append(NextChargeEndSensor2(charger)) async_add_entities(entities, update_before_add=True) @@ -113,3 +121,109 @@ def __init__(self, meter): async def async_update(self) -> None: self._attr_native_value = await self.meter.get_latest_reading() + +class SmartChargingScheduleSensor(SensorEntity): + """Smart Charging Schedule""" + + def __init__(self, charger): + self.charger = charger + + self._attr_name = self.charger.get_serial() + " Smart Charging Schedule" + self._attr_icon = "mdi:ev-station" + self._attr_unique_id = self.charger.get_serial() + "__" + "smart_charging_schedule" + self._attr_extra_state_attributes = {} + + + async def async_update(self) -> None: + schedule = await self.charger.get_schedule() + if schedule is not None: + if len(schedule) > 0: + self._attr_native_value = "Active" + self._attr_extra_state_attributes["schedule"] = schedule + else: + self._attr_native_value = "No Schedule" + self._attr_extra_state_attributes["schedule"] = [] + else: + self._attr_native_value = "Unknown" + + +class NextChargeStartSensor(SensorEntity): + """Start time of next charge""" + + def __init__(self, charger): + self.charger = charger + + self._attr_name = self.charger.get_serial() + " Next Charge Start" + self._attr_device_class = SensorDeviceClass.TIMESTAMP + self._attr_icon = "mdi:clock-start" + self._attr_unique_id = self.charger.get_serial() + "__" + "next_charge_start" + + + async def async_update(self) -> None: + schedule = await self.charger.get_schedule() + if schedule and len(schedule) > 0: + self._attr_native_value = dt_util.parse_datetime(schedule[0]['start']) + else: + self._attr_native_value = None + + +class NextChargeEndSensor(SensorEntity): + """End time of next charge""" + + def __init__(self, charger): + self.charger = charger + + self._attr_name = self.charger.get_serial() + " Next Charge End" + self._attr_device_class = SensorDeviceClass.TIMESTAMP + self._attr_icon = "mdi:clock-end" + self._attr_unique_id = self.charger.get_serial() + "__" + "next_charge_end" + + + async def async_update(self) -> None: + schedule = await self.charger.get_schedule() + if schedule and len(schedule) > 0: + self._attr_native_value = dt_util.parse_datetime(schedule[0]['end']) + else: + self._attr_native_value = None + + +class NextChargeStartSensor2(SensorEntity): + """Start time of next charge slot 2""" + + def __init__(self, charger): + self.charger = charger + + self._attr_name = self.charger.get_serial() + " Next Charge Start 2" + self._attr_device_class = SensorDeviceClass.TIMESTAMP + self._attr_icon = "mdi:clock-start" + self._attr_unique_id = self.charger.get_serial() + "__" + "next_charge_start_2" + + + async def async_update(self) -> None: + schedule = await self.charger.get_schedule() + if schedule and len(schedule) > 1: + self._attr_native_value = dt_util.parse_datetime(schedule[1]['start']) + else: + self._attr_native_value = None + + +class NextChargeEndSensor2(SensorEntity): + """End time of next charge slot 2""" + + def __init__(self, charger): + self.charger = charger + + self._attr_name = self.charger.get_serial() + " Next Charge End 2" + self._attr_device_class = SensorDeviceClass.TIMESTAMP + self._attr_icon = "mdi:clock-end" + self._attr_unique_id = self.charger.get_serial() + "__" + "next_charge_end_2" + + + async def async_update(self) -> None: + schedule = await self.charger.get_schedule() + if schedule and len(schedule) > 1: + self._attr_native_value = dt_util.parse_datetime(schedule[1]['end']) + else: + self._attr_native_value = None + + From 8a6b5122c88172187a6dd6391ee4cf71473b752c Mon Sep 17 00:00:00 2001 From: Steve Aitken Date: Sun, 14 Dec 2025 21:09:21 +0000 Subject: [PATCH 03/10] feat: add tariff details and saving sessions support - Add tariff sensors: tariff name, standing charge, unit rate - Add saving sessions sensor with active/upcoming counts - Load tariff data and saving sessions during account init - Update README with new sensor documentation - Include tariff code, validity dates, and meter point in attributes --- .gitignore | 3 +- README.md | 14 +++ custom_components/eon_next/eonnext.py | 37 +++++++ custom_components/eon_next/sensor.py | 146 ++++++++++++++++++++++++++ 4 files changed, 199 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b2c602e..d82eeff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ notes -test.py \ No newline at end of file +test.py +documents/investigation_plan.md diff --git a/README.md b/README.md index e71609a..6cf519c 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,20 @@ For each connected smart charger, the following sensors are created: These sensors allow you to monitor and automate your EV charging based on Eon Next's smart charging recommendations. +### Tariff Information +For each account, the following tariff sensors are created: + +- **Tariff Name**: The display name of your active tariff +- **Standing Charge**: Daily standing charge in GBP/day +- **Unit Rate**: Energy unit rate in GBP/kWh + +These sensors include additional attributes such as tariff code, valid dates, and meter point information. + +### Saving Sessions +If your account participates in saving sessions (similar to Octopus Energy's scheme): + +- **Saving Sessions**: Count of active and upcoming saving sessions, with full session details in attributes including start/end times, reward amounts, and session status + ## Installation diff --git a/custom_components/eon_next/eonnext.py b/custom_components/eon_next/eonnext.py index 6bebcfe..2ca5011 100644 --- a/custom_components/eon_next/eonnext.py +++ b/custom_components/eon_next/eonnext.py @@ -202,6 +202,8 @@ async def __init_accounts(self): account = EnergyAccount(self, account_number) await account._load_meters() await account._load_ev_chargers() + await account._load_tariff_data() + await account._load_saving_sessions() self.accounts.append(account) @@ -214,6 +216,41 @@ def __init__(self, api: EonNext, account_number: str): self.api = api self.account_number = account_number self.ev_chargers = [] + self.tariff_data = None + self.saving_sessions = [] + + + async def _load_tariff_data(self): + """Load active tariff/agreement details for this account""" + result = await self.api._graphql_post( + "getAccountAgreements", + "query getAccountAgreements($accountNumber: String!) {\n account(accountNumber: $accountNumber) {\n agreements {\n id\n validFrom\n validTo\n tariff {\n displayName\n fullName\n standingCharge\n unitRate\n tariffCode\n ... on TariffType {\n isVariable\n tariffType\n }\n }\n meterPoint {\n mpan\n mprn\n }\n }\n }\n}\n", + { + "accountNumber": self.account_number + } + ) + + if self.api._json_contains_key_chain(result, ["data", "account", "agreements"]): + # Store the raw agreements data + self.tariff_data = result['data']['account']['agreements'] + else: + self.tariff_data = [] + + + async def _load_saving_sessions(self): + """Load saving session data (similar to Octopus Saving Sessions)""" + result = await self.api._graphql_post( + "getSavingSessions", + "query getSavingSessions($accountNumber: String!) {\n savingSessions(accountNumber: $accountNumber) {\n id\n code\n startAt\n endAt\n rewardAmount\n state\n }\n}\n", + { + "accountNumber": self.account_number + } + ) + + if self.api._json_contains_key_chain(result, ["data", "savingSessions"]): + self.saving_sessions = result['data']['savingSessions'] + else: + self.saving_sessions = [] async def _load_ev_chargers(self): diff --git a/custom_components/eon_next/sensor.py b/custom_components/eon_next/sensor.py index f57012c..f339d97 100755 --- a/custom_components/eon_next/sensor.py +++ b/custom_components/eon_next/sensor.py @@ -44,6 +44,16 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities.append(NextChargeEndSensor(charger)) entities.append(NextChargeStartSensor2(charger)) entities.append(NextChargeEndSensor2(charger)) + + # Add tariff sensors for the account + if account.tariff_data: + entities.append(TariffNameSensor(account)) + entities.append(StandingChargeSensor(account)) + entities.append(UnitRateSensor(account)) + + # Add saving session sensors + if account.saving_sessions: + entities.append(SavingSessionsSensor(account)) async_add_entities(entities, update_before_add=True) @@ -227,3 +237,139 @@ async def async_update(self) -> None: self._attr_native_value = None +class TariffNameSensor(SensorEntity): + """Active tariff name for the account""" + + def __init__(self, account): + self.account = account + + self._attr_name = f"Account {self.account.account_number} Tariff Name" + self._attr_icon = "mdi:file-document-outline" + self._attr_unique_id = f"{self.account.account_number}__tariff_name" + + + async def async_update(self) -> None: + await self.account._load_tariff_data() + if self.account.tariff_data and len(self.account.tariff_data) > 0: + # Get the most recent active agreement + active = [a for a in self.account.tariff_data if not a.get('validTo') or dt_util.parse_datetime(a['validTo']) > dt_util.now()] + if active: + tariff = active[0].get('tariff', {}) + self._attr_native_value = tariff.get('displayName') or tariff.get('fullName') + self._attr_extra_state_attributes = { + "tariff_code": tariff.get('tariffCode'), + "tariff_type": tariff.get('tariffType'), + "is_variable": tariff.get('isVariable'), + "valid_from": active[0].get('validFrom'), + "valid_to": active[0].get('validTo') + } + else: + self._attr_native_value = None + else: + self._attr_native_value = None + + +class StandingChargeSensor(SensorEntity): + """Daily standing charge for the account""" + + def __init__(self, account): + self.account = account + + self._attr_name = f"Account {self.account.account_number} Standing Charge" + self._attr_icon = "mdi:currency-gbp" + self._attr_unit_of_measurement = "GBP/day" + self._attr_unique_id = f"{self.account.account_number}__standing_charge" + + + async def async_update(self) -> None: + await self.account._load_tariff_data() + if self.account.tariff_data and len(self.account.tariff_data) > 0: + active = [a for a in self.account.tariff_data if not a.get('validTo') or dt_util.parse_datetime(a['validTo']) > dt_util.now()] + if active: + tariff = active[0].get('tariff', {}) + standing_charge = tariff.get('standingCharge') + if standing_charge is not None: + # Convert pence to pounds + self._attr_native_value = round(standing_charge / 100, 4) + else: + self._attr_native_value = None + else: + self._attr_native_value = None + else: + self._attr_native_value = None + + +class UnitRateSensor(SensorEntity): + """Unit rate for the account""" + + def __init__(self, account): + self.account = account + + self._attr_name = f"Account {self.account.account_number} Unit Rate" + self._attr_icon = "mdi:currency-gbp" + self._attr_unit_of_measurement = "GBP/kWh" + self._attr_unique_id = f"{self.account.account_number}__unit_rate" + + + async def async_update(self) -> None: + await self.account._load_tariff_data() + if self.account.tariff_data and len(self.account.tariff_data) > 0: + active = [a for a in self.account.tariff_data if not a.get('validTo') or dt_util.parse_datetime(a['validTo']) > dt_util.now()] + if active: + tariff = active[0].get('tariff', {}) + unit_rate = tariff.get('unitRate') + if unit_rate is not None: + # Convert pence to pounds + self._attr_native_value = round(unit_rate / 100, 4) + self._attr_extra_state_attributes = { + "meter_point": active[0].get('meterPoint', {}).get('mpan') or active[0].get('meterPoint', {}).get('mprn') + } + else: + self._attr_native_value = None + else: + self._attr_native_value = None + else: + self._attr_native_value = None + + +class SavingSessionsSensor(SensorEntity): + """Upcoming and active saving sessions""" + + def __init__(self, account): + self.account = account + + self._attr_name = f"Account {self.account.account_number} Saving Sessions" + self._attr_icon = "mdi:piggy-bank-outline" + self._attr_unique_id = f"{self.account.account_number}__saving_sessions" + + + async def async_update(self) -> None: + await self.account._load_saving_sessions() + if self.account.saving_sessions: + # Count active/upcoming sessions + now = dt_util.now() + upcoming = [s for s in self.account.saving_sessions if dt_util.parse_datetime(s['startAt']) > now] + active = [s for s in self.account.saving_sessions if dt_util.parse_datetime(s['startAt']) <= now <= dt_util.parse_datetime(s['endAt'])] + + self._attr_native_value = len(upcoming) + len(active) + self._attr_extra_state_attributes = { + "active_count": len(active), + "upcoming_count": len(upcoming), + "sessions": [ + { + "code": s.get('code'), + "start": s.get('startAt'), + "end": s.get('endAt'), + "reward": s.get('rewardAmount'), + "state": s.get('state') + } + for s in self.account.saving_sessions + ] + } + else: + self._attr_native_value = 0 + self._attr_extra_state_attributes = { + "active_count": 0, + "upcoming_count": 0, + "sessions": [] + } From ee7db99c56d4f97891741dd70548ec84b0ca94b5 Mon Sep 17 00:00:00 2001 From: Steve Aitken Date: Sun, 14 Dec 2025 22:11:29 +0000 Subject: [PATCH 04/10] Fix GraphQL queries and add Next Drive tariff logic --- .gitignore | 1 + custom_components/eon_next/eonnext.py | 30 +++++++---- custom_components/eon_next/sensor.py | 72 +++++++++++++++++++++++---- 3 files changed, 83 insertions(+), 20 deletions(-) diff --git a/.gitignore b/.gitignore index d82eeff..189511d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ notes test.py documents/investigation_plan.md +.github/copilot-instructions.md diff --git a/custom_components/eon_next/eonnext.py b/custom_components/eon_next/eonnext.py index 2ca5011..7abfd3c 100644 --- a/custom_components/eon_next/eonnext.py +++ b/custom_components/eon_next/eonnext.py @@ -23,6 +23,8 @@ def __init__(self): def _json_contains_key_chain(self, data: dict, key_chain: list) -> bool: for key in key_chain: + if data is None: + return False if key in data: data = data[key] else: @@ -218,37 +220,44 @@ def __init__(self, api: EonNext, account_number: str): self.ev_chargers = [] self.tariff_data = None self.saving_sessions = [] + self.postcode = "" async def _load_tariff_data(self): """Load active tariff/agreement details for this account""" result = await self.api._graphql_post( "getAccountAgreements", - "query getAccountAgreements($accountNumber: String!) {\n account(accountNumber: $accountNumber) {\n agreements {\n id\n validFrom\n validTo\n tariff {\n displayName\n fullName\n standingCharge\n unitRate\n tariffCode\n ... on TariffType {\n isVariable\n tariffType\n }\n }\n meterPoint {\n mpan\n mprn\n }\n }\n }\n}\n", + "query getAccountAgreements($accountNumber: String!) { properties(accountNumber: $accountNumber) { electricityMeterPoints { mpan agreements { id validFrom validTo tariff { __typename ... on TariffType { displayName fullName tariffCode } ... on StandardTariff { unitRate standingCharge } ... on PrepayTariff { unitRate standingCharge } ... on HalfHourlyTariff { unitRates { value } standingCharge } } } } } }", { "accountNumber": self.account_number } ) - if self.api._json_contains_key_chain(result, ["data", "account", "agreements"]): - # Store the raw agreements data - self.tariff_data = result['data']['account']['agreements'] - else: - self.tariff_data = [] + self.tariff_data = [] + if self.api._json_contains_key_chain(result, ["data", "properties"]): + for prop in result['data']['properties']: + if 'electricityMeterPoints' in prop: + for point in prop['electricityMeterPoints']: + mpan = point.get('mpan') + if 'agreements' in point: + for agreement in point['agreements']: + # Inject meterPoint info for sensor compatibility + agreement['meterPoint'] = {'mpan': mpan} + self.tariff_data.append(agreement) async def _load_saving_sessions(self): """Load saving session data (similar to Octopus Saving Sessions)""" result = await self.api._graphql_post( "getSavingSessions", - "query getSavingSessions($accountNumber: String!) {\n savingSessions(accountNumber: $accountNumber) {\n id\n code\n startAt\n endAt\n rewardAmount\n state\n }\n}\n", + "query getSavingSessions($postcode: String!) { appSessions(postcode: $postcode) { edges { node { id startedAt __typename } } } }", { - "accountNumber": self.account_number + "postcode": self.postcode } ) - if self.api._json_contains_key_chain(result, ["data", "savingSessions"]): - self.saving_sessions = result['data']['savingSessions'] + if self.api._json_contains_key_chain(result, ["data", "appSessions", "edges"]): + self.saving_sessions = [edge['node'] for edge in result['data']['appSessions']['edges']] else: self.saving_sessions = [] @@ -287,6 +296,7 @@ async def _load_meters(self): self.meters = [] for property in result['data']['properties']: + self.postcode = property.get('postcode') for electricity_point in property['electricityMeterPoints']: for meter_config in electricity_point['meters']: diff --git a/custom_components/eon_next/sensor.py b/custom_components/eon_next/sensor.py index f339d97..685dafd 100755 --- a/custom_components/eon_next/sensor.py +++ b/custom_components/eon_next/sensor.py @@ -318,12 +318,53 @@ async def async_update(self) -> None: if active: tariff = active[0].get('tariff', {}) unit_rate = tariff.get('unitRate') + + # Handle HalfHourlyTariff with multiple rates + if unit_rate is None and tariff.get('unitRates'): + rates = tariff.get('unitRates') + if len(rates) > 0: + # Extract unique rates + unique_rates = sorted(list(set([r['value'] for r in rates]))) + + # Default to the first rate (usually low/night rate if sorted, but we want current) + # Logic for Next Drive: 00:00 - 07:00 is Off-Peak (Low) + is_next_drive = "Next Drive" in (tariff.get('displayName') or "") + + if is_next_drive and len(unique_rates) >= 2: + low_rate = unique_rates[0] + high_rate = unique_rates[1] # Assuming 2 rates for now + + now = dt_util.now() + # Next Drive Off-Peak is 00:00 to 07:00 + if 0 <= now.hour < 7: + unit_rate = low_rate + current_period = "Off-Peak" + else: + unit_rate = high_rate + current_period = "Peak" + + self._attr_extra_state_attributes = { + "meter_point": active[0].get('meterPoint', {}).get('mpan') or active[0].get('meterPoint', {}).get('mprn'), + "rates": unique_rates, + "current_period": current_period, + "low_rate": round(low_rate / 100, 4), + "high_rate": round(high_rate / 100, 4) + } + else: + # Fallback for unknown multi-rate tariffs + unit_rate = rates[0].get('value') + self._attr_extra_state_attributes = { + "meter_point": active[0].get('meterPoint', {}).get('mpan') or active[0].get('meterPoint', {}).get('mprn'), + "rates": unique_rates + } + if unit_rate is not None: # Convert pence to pounds self._attr_native_value = round(unit_rate / 100, 4) - self._attr_extra_state_attributes = { - "meter_point": active[0].get('meterPoint', {}).get('mpan') or active[0].get('meterPoint', {}).get('mprn') - } + if not hasattr(self, '_attr_extra_state_attributes'): + self._attr_extra_state_attributes = { + "meter_point": active[0].get('meterPoint', {}).get('mpan') or active[0].get('meterPoint', {}).get('mprn') + } else: self._attr_native_value = None else: @@ -348,20 +389,31 @@ async def async_update(self) -> None: if self.account.saving_sessions: # Count active/upcoming sessions now = dt_util.now() - upcoming = [s for s in self.account.saving_sessions if dt_util.parse_datetime(s['startAt']) > now] - active = [s for s in self.account.saving_sessions if dt_util.parse_datetime(s['startAt']) <= now <= dt_util.parse_datetime(s['endAt'])] + upcoming = [] + active = [] + for s in self.account.saving_sessions: + start_str = s.get('startedAt') or s.get('startAt') + end_str = s.get('endedAt') or s.get('endAt') + + if start_str: + start_dt = dt_util.parse_datetime(start_str) + if start_dt > now: + upcoming.append(s) + elif end_str: + end_dt = dt_util.parse_datetime(end_str) + if start_dt <= now <= end_dt: + active.append(s) + self._attr_native_value = len(upcoming) + len(active) self._attr_extra_state_attributes = { "active_count": len(active), "upcoming_count": len(upcoming), "sessions": [ { - "code": s.get('code'), - "start": s.get('startAt'), - "end": s.get('endAt'), - "reward": s.get('rewardAmount'), - "state": s.get('state') + "id": s.get('id'), + "start": s.get('startedAt') or s.get('startAt'), + "type": s.get('type') } for s in self.account.saving_sessions ] From ed5f1f8a507b1f6c6b742b09d04f175b72037d19 Mon Sep 17 00:00:00 2001 From: Steve Aitken Date: Tue, 21 Apr 2026 14:22:23 +0100 Subject: [PATCH 05/10] Add billing history sensor and improve tariff rate detection --- custom_components/eon_next/eonnext.py | 26 ++++++++ custom_components/eon_next/sensor.py | 94 ++++++++++++++++----------- 2 files changed, 81 insertions(+), 39 deletions(-) diff --git a/custom_components/eon_next/eonnext.py b/custom_components/eon_next/eonnext.py index 7abfd3c..4f11389 100644 --- a/custom_components/eon_next/eonnext.py +++ b/custom_components/eon_next/eonnext.py @@ -206,6 +206,7 @@ async def __init_accounts(self): await account._load_ev_chargers() await account._load_tariff_data() await account._load_saving_sessions() + await account._load_billing_data() self.accounts.append(account) @@ -220,6 +221,7 @@ def __init__(self, api: EonNext, account_number: str): self.ev_chargers = [] self.tariff_data = None self.saving_sessions = [] + self.billing_data = [] self.postcode = "" @@ -262,6 +264,22 @@ async def _load_saving_sessions(self): self.saving_sessions = [] + async def _load_billing_data(self): + """Load billing history for the account""" + result = await self.api._graphql_post( + "getAccountBilling", + "query getAccountBilling($accountNumber: String!) { account(accountNumber: $accountNumber) { bills(first: 10) { edges { node { id billDate dueDate amount status } } } } }", + { + "accountNumber": self.account_number + } + ) + + if self.api._json_contains_key_chain(result, ["data", "account", "bills", "edges"]): + self.billing_data = [edge['node'] for edge in result['data']['account']['bills']['edges']] + else: + self.billing_data = [] + + async def _load_ev_chargers(self): result = await self.api._graphql_post( "getAccountDevices", @@ -453,6 +471,14 @@ def __init__(self, account: EnergyAccount, meter_id: str, serial: str): super().__init__(account, meter_id, serial) self.type = METER_TYPE_EV self.schedule = None + + + def _should_update(self) -> bool: + if self.last_updated == None: + return True + + now = datetime.datetime.now() + return (now - self.last_updated) >= datetime.timedelta(minutes=5) async def _update(self): diff --git a/custom_components/eon_next/sensor.py b/custom_components/eon_next/sensor.py index 685dafd..9aab063 100755 --- a/custom_components/eon_next/sensor.py +++ b/custom_components/eon_next/sensor.py @@ -54,6 +54,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): # Add saving session sensors if account.saving_sessions: entities.append(SavingSessionsSensor(account)) + + # Add billing sensors + entities.append(BillingHistorySensor(account)) async_add_entities(entities, update_before_add=True) @@ -258,8 +261,6 @@ async def async_update(self) -> None: self._attr_native_value = tariff.get('displayName') or tariff.get('fullName') self._attr_extra_state_attributes = { "tariff_code": tariff.get('tariffCode'), - "tariff_type": tariff.get('tariffType'), - "is_variable": tariff.get('isVariable'), "valid_from": active[0].get('validFrom'), "valid_to": active[0].get('validTo') } @@ -309,6 +310,7 @@ def __init__(self, account): self._attr_icon = "mdi:currency-gbp" self._attr_unit_of_measurement = "GBP/kWh" self._attr_unique_id = f"{self.account.account_number}__unit_rate" + self._attr_extra_state_attributes = {} async def async_update(self) -> None: @@ -322,47 +324,33 @@ async def async_update(self) -> None: # Handle HalfHourlyTariff with multiple rates if unit_rate is None and tariff.get('unitRates'): rates = tariff.get('unitRates') - if len(rates) > 0: - # Extract unique rates - unique_rates = sorted(list(set([r['value'] for r in rates]))) - - # Default to the first rate (usually low/night rate if sorted, but we want current) - # Logic for Next Drive: 00:00 - 07:00 is Off-Peak (Low) - is_next_drive = "Next Drive" in (tariff.get('displayName') or "") - - if is_next_drive and len(unique_rates) >= 2: - low_rate = unique_rates[0] - high_rate = unique_rates[1] # Assuming 2 rates for now - - now = dt_util.now() - # Next Drive Off-Peak is 00:00 to 07:00 - if 0 <= now.hour < 7: - unit_rate = low_rate - current_period = "Off-Peak" - else: - unit_rate = high_rate - current_period = "Peak" - - self._attr_extra_state_attributes = { - "meter_point": active[0].get('meterPoint', {}).get('mpan') or active[0].get('meterPoint', {}).get('mprn'), - "rates": unique_rates, - "current_period": current_period, - "low_rate": round(low_rate / 100, 4), - "high_rate": round(high_rate / 100, 4) - } - else: - # Fallback for unknown multi-rate tariffs - unit_rate = rates[0].get('value') - self._attr_extra_state_attributes = { - "meter_point": active[0].get('meterPoint', {}).get('mpan') or active[0].get('meterPoint', {}).get('mprn'), - "rates": unique_rates - } + unique_rates = sorted(list(set([r['value'] for r in rates]))) + now = dt_util.now() + + if len(rates) == 48: + # 48-slot half-hourly tariff — index directly from current time + slot = now.hour * 2 + (1 if now.minute >= 30 else 0) + unit_rate = rates[slot]['value'] + elif len(rates) > 0: + unit_rate = rates[0]['value'] + + low_rate = unique_rates[0] + high_rate = unique_rates[-1] + current_period = "Off-Peak" if unit_rate == low_rate else "Peak" + self._attr_extra_state_attributes = { + "meter_point": active[0].get('meterPoint', {}).get('mpan') or active[0].get('meterPoint', {}).get('mprn'), + "all_rates_p": unique_rates, + "current_period": current_period, + "low_rate": round(low_rate / 100, 4), + "high_rate": round(high_rate / 100, 4), + "rate_slots": len(rates) + } if unit_rate is not None: # Convert pence to pounds self._attr_native_value = round(unit_rate / 100, 4) - if not hasattr(self, '_attr_extra_state_attributes'): - self._attr_extra_state_attributes = { + if not self._attr_extra_state_attributes: + self._attr_extra_state_attributes = { "meter_point": active[0].get('meterPoint', {}).get('mpan') or active[0].get('meterPoint', {}).get('mprn') } else: @@ -425,3 +413,31 @@ async def async_update(self) -> None: "upcoming_count": 0, "sessions": [] } + + +class BillingHistorySensor(SensorEntity): + """Latest bill information for the account""" + + def __init__(self, account): + self.account = account + + self._attr_name = f"Account {self.account.account_number} Latest Bill" + self._attr_icon = "mdi:receipt" + self._attr_unit_of_measurement = "GBP" + self._attr_unique_id = f"{self.account.account_number}__latest_bill" + + + async def async_update(self) -> None: + await self.account._load_billing_data() + if self.account.billing_data and len(self.account.billing_data) > 0: + # Get the most recent bill + latest_bill = sorted(self.account.billing_data, key=lambda x: x.get('billDate'), reverse=True)[0] + self._attr_native_value = latest_bill.get('amount', 0) / 100 # Convert pence to pounds + self._attr_extra_state_attributes = { + "bill_date": latest_bill.get('billDate'), + "due_date": latest_bill.get('dueDate'), + "status": latest_bill.get('status'), + "bill_id": latest_bill.get('id') + } + else: + self._attr_native_value = None From 5920a6b2f980d9db63bddbaf76de40fb75dc8ae8 Mon Sep 17 00:00:00 2001 From: Steve Aitken Date: Tue, 21 Apr 2026 14:28:50 +0100 Subject: [PATCH 06/10] Fix: Call _load_accounts() to fetch EON account data --- custom_components/eon_next/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/custom_components/eon_next/__init__.py b/custom_components/eon_next/__init__.py index aaf2829..7e5e2e0 100755 --- a/custom_components/eon_next/__init__.py +++ b/custom_components/eon_next/__init__.py @@ -18,7 +18,9 @@ async def async_setup_entry(hass, entry): success = await api.login_with_username_and_password(entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD]) if success == True: - + # Load account data from EON API + await api._load_accounts() + hass.data[DOMAIN][entry.entry_id] = api await hass.config_entries.async_forward_entry_setups(entry, ["sensor"]) From 3b66f515712bf8c1734fce9cb2c2ba6d0d69c7d3 Mon Sep 17 00:00:00 2001 From: Steve Aitken Date: Tue, 21 Apr 2026 14:40:14 +0100 Subject: [PATCH 07/10] Fix: Use correct name-mangled method _EonNext__init_accounts() to load accounts --- custom_components/eon_next/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/eon_next/__init__.py b/custom_components/eon_next/__init__.py index 7e5e2e0..5095141 100755 --- a/custom_components/eon_next/__init__.py +++ b/custom_components/eon_next/__init__.py @@ -18,8 +18,8 @@ async def async_setup_entry(hass, entry): success = await api.login_with_username_and_password(entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD]) if success == True: - # Load account data from EON API - await api._load_accounts() + # Load account data from EON API (use name-mangled private method) + await api._EonNext__init_accounts() hass.data[DOMAIN][entry.entry_id] = api From 4c8c6607f49eede09f608c2cbf68da4e82865ada Mon Sep 17 00:00:00 2001 From: Steve Aitken Date: Tue, 21 Apr 2026 14:44:05 +0100 Subject: [PATCH 08/10] Fix: Correctly select current rate for 2-rate tariffs (Next Drive: Off-Peak 00:00-07:00, Peak 07:00-24:00) --- custom_components/eon_next/sensor.py | 46 ++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/custom_components/eon_next/sensor.py b/custom_components/eon_next/sensor.py index 9aab063..6609633 100755 --- a/custom_components/eon_next/sensor.py +++ b/custom_components/eon_next/sensor.py @@ -331,20 +331,42 @@ async def async_update(self) -> None: # 48-slot half-hourly tariff — index directly from current time slot = now.hour * 2 + (1 if now.minute >= 30 else 0) unit_rate = rates[slot]['value'] + elif len(rates) == 2: + # 2-rate tariff (e.g., Next Drive: 00:00-07:00 Off-Peak, 07:00-24:00 Peak) + # Low rate is off-peak (00:00-07:00), high rate is peak (07:00-24:00) + low_rate = unique_rates[0] + high_rate = unique_rates[1] + + if 0 <= now.hour < 7: + unit_rate = low_rate # Off-Peak + current_period = "Off-Peak" + else: + unit_rate = high_rate # Peak + current_period = "Peak" + + self._attr_extra_state_attributes = { + "meter_point": active[0].get('meterPoint', {}).get('mpan') or active[0].get('meterPoint', {}).get('mprn'), + "all_rates_p": unique_rates, + "current_period": current_period, + "low_rate": round(low_rate / 100, 4), + "high_rate": round(high_rate / 100, 4), + "rate_slots": len(rates) + } elif len(rates) > 0: + # Fallback: use first rate unit_rate = rates[0]['value'] - - low_rate = unique_rates[0] - high_rate = unique_rates[-1] - current_period = "Off-Peak" if unit_rate == low_rate else "Peak" - self._attr_extra_state_attributes = { - "meter_point": active[0].get('meterPoint', {}).get('mpan') or active[0].get('meterPoint', {}).get('mprn'), - "all_rates_p": unique_rates, - "current_period": current_period, - "low_rate": round(low_rate / 100, 4), - "high_rate": round(high_rate / 100, 4), - "rate_slots": len(rates) - } + + low_rate = unique_rates[0] + high_rate = unique_rates[-1] + current_period = "Off-Peak" if unit_rate == low_rate else "Peak" + self._attr_extra_state_attributes = { + "meter_point": active[0].get('meterPoint', {}).get('mpan') or active[0].get('meterPoint', {}).get('mprn'), + "all_rates_p": unique_rates, + "current_period": current_period, + "low_rate": round(low_rate / 100, 4), + "high_rate": round(high_rate / 100, 4), + "rate_slots": len(rates) + } if unit_rate is not None: # Convert pence to pounds From 4891e65dfb01737ec2d34e88a2d16df6c56e9e0a Mon Sep 17 00:00:00 2001 From: Steve Aitken Date: Tue, 21 Apr 2026 15:20:14 +0100 Subject: [PATCH 09/10] Add EV charger schedule detection to use off-peak rate during charging windows --- custom_components/eon_next/eonnext.py | 2 +- custom_components/eon_next/sensor.py | 126 +++++++++++------ custom_components/eon_next/tariff_patterns.py | 127 ++++++++++++++++++ 3 files changed, 213 insertions(+), 42 deletions(-) create mode 100644 custom_components/eon_next/tariff_patterns.py diff --git a/custom_components/eon_next/eonnext.py b/custom_components/eon_next/eonnext.py index 4f11389..3e44172 100644 --- a/custom_components/eon_next/eonnext.py +++ b/custom_components/eon_next/eonnext.py @@ -229,7 +229,7 @@ async def _load_tariff_data(self): """Load active tariff/agreement details for this account""" result = await self.api._graphql_post( "getAccountAgreements", - "query getAccountAgreements($accountNumber: String!) { properties(accountNumber: $accountNumber) { electricityMeterPoints { mpan agreements { id validFrom validTo tariff { __typename ... on TariffType { displayName fullName tariffCode } ... on StandardTariff { unitRate standingCharge } ... on PrepayTariff { unitRate standingCharge } ... on HalfHourlyTariff { unitRates { value } standingCharge } } } } } }", + "query getAccountAgreements($accountNumber: String!) { properties(accountNumber: $accountNumber) { electricityMeterPoints { mpan agreements { id validFrom validTo tariff { __typename ... on TariffType { displayName fullName tariffCode } ... on StandardTariff { unitRate standingCharge } ... on PrepayTariff { unitRate standingCharge } ... on HalfHourlyTariff { unitRates { value validFrom validTo rateType } standingCharge } } } } } }", { "accountNumber": self.account_number } diff --git a/custom_components/eon_next/sensor.py b/custom_components/eon_next/sensor.py index 6609633..ab6f8ff 100755 --- a/custom_components/eon_next/sensor.py +++ b/custom_components/eon_next/sensor.py @@ -15,6 +15,7 @@ from . import DOMAIN from .eonnext import METER_TYPE_GAS, METER_TYPE_ELECTRIC, METER_TYPE_EV +from .tariff_patterns import get_tariff_pattern, get_current_rate_index, get_current_period_name _LOGGER = logging.getLogger(__name__) @@ -315,60 +316,103 @@ def __init__(self, account): async def async_update(self) -> None: await self.account._load_tariff_data() + + # Check for active EV charger schedules + ev_charger_active = False + if self.account.ev_chargers: + now = dt_util.now() + for charger in self.account.ev_chargers: + schedule = await charger.get_schedule() + if schedule: + for dispatch in schedule: + start = dt_util.parse_datetime(dispatch.get('start')) + end = dt_util.parse_datetime(dispatch.get('end')) + if start and end and start <= now <= end: + ev_charger_active = True + break + if ev_charger_active: + break + if self.account.tariff_data and len(self.account.tariff_data) > 0: active = [a for a in self.account.tariff_data if not a.get('validTo') or dt_util.parse_datetime(a['validTo']) > dt_util.now()] if active: tariff = active[0].get('tariff', {}) unit_rate = tariff.get('unitRate') - # Handle HalfHourlyTariff with multiple rates + # Handle HalfHourlyTariff with multiple rates if unit_rate is None and tariff.get('unitRates'): rates = tariff.get('unitRates') unique_rates = sorted(list(set([r['value'] for r in rates]))) now = dt_util.now() - if len(rates) == 48: - # 48-slot half-hourly tariff — index directly from current time - slot = now.hour * 2 + (1 if now.minute >= 30 else 0) - unit_rate = rates[slot]['value'] - elif len(rates) == 2: - # 2-rate tariff (e.g., Next Drive: 00:00-07:00 Off-Peak, 07:00-24:00 Peak) - # Low rate is off-peak (00:00-07:00), high rate is peak (07:00-24:00) - low_rate = unique_rates[0] - high_rate = unique_rates[1] - - if 0 <= now.hour < 7: - unit_rate = low_rate # Off-Peak - current_period = "Off-Peak" - else: - unit_rate = high_rate # Peak - current_period = "Peak" - + # If EV charger is active, use the lower (off-peak) rate + if ev_charger_active and len(unique_rates) >= 2: + unit_rate = unique_rates[0] # Lower rate self._attr_extra_state_attributes = { - "meter_point": active[0].get('meterPoint', {}).get('mpan') or active[0].get('meterPoint', {}).get('mprn'), - "all_rates_p": unique_rates, - "current_period": current_period, - "low_rate": round(low_rate / 100, 4), - "high_rate": round(high_rate / 100, 4), - "rate_slots": len(rates) - } - elif len(rates) > 0: - # Fallback: use first rate - unit_rate = rates[0]['value'] + "meter_point": active[0].get('meterPoint', {}).get('mpan') or active[0].get('meterPoint', {}).get('mprn'), + "all_rates_p": unique_rates, + "current_period": "Charging (Off-Peak Rate Applied)", + "low_rate": round(unique_rates[0] / 100, 4), + "high_rate": round(unique_rates[-1] / 100, 4), + "rate_slots": len(rates), + "charging_active": True, + "using_off_peak_rate": True + } + else: + # Try to get tariff pattern for time-based rate selection + tariff_name = tariff.get('displayName') or tariff.get('fullName') or '' + pattern = get_tariff_pattern(tariff_name) - low_rate = unique_rates[0] - high_rate = unique_rates[-1] - current_period = "Off-Peak" if unit_rate == low_rate else "Peak" - self._attr_extra_state_attributes = { - "meter_point": active[0].get('meterPoint', {}).get('mpan') or active[0].get('meterPoint', {}).get('mprn'), - "all_rates_p": unique_rates, - "current_period": current_period, - "low_rate": round(low_rate / 100, 4), - "high_rate": round(high_rate / 100, 4), - "rate_slots": len(rates) - } - - if unit_rate is not None: + if len(rates) == 48: + # 48-slot half-hourly tariff — index directly from current time + slot = now.hour * 2 + (1 if now.minute >= 30 else 0) + unit_rate = rates[slot]['value'] + + self._attr_extra_state_attributes = { + "meter_point": active[0].get('meterPoint', {}).get('mpan') or active[0].get('meterPoint', {}).get('mprn'), + "all_rates_p": unique_rates, + "current_slot": slot, + "rate_slots": len(rates) + } + elif len(rates) == 2 and pattern: + # 2-rate tariff with known pattern (e.g., Next Drive, Economy 7) + rate_index = get_current_rate_index(pattern, now.hour) + unit_rate = rates[rate_index]['value'] + + low_rate = unique_rates[0] + high_rate = unique_rates[-1] + current_period = get_current_period_name(pattern, now.hour) + + off_peak_str, peak_str = self._get_period_hours(pattern) + + self._attr_extra_state_attributes = { + "meter_point": active[0].get('meterPoint', {}).get('mpan') or active[0].get('meterPoint', {}).get('mprn'), + "all_rates_p": unique_rates, + "current_period": current_period, + "low_rate": round(low_rate / 100, 4), + "high_rate": round(high_rate / 100, 4), + "rate_slots": len(rates), + "off_peak_hours": off_peak_str, + "peak_hours": peak_str, + "tariff_pattern": pattern.get('description', tariff_name) + } + elif len(rates) > 0: + # Fallback: use first rate for unknown multi-rate tariffs + unit_rate = rates[0]['value'] + + low_rate = unique_rates[0] + high_rate = unique_rates[-1] + current_period = "Unknown" + + self._attr_extra_state_attributes = { + "meter_point": active[0].get('meterPoint', {}).get('mpan') or active[0].get('meterPoint', {}).get('mprn'), + "all_rates_p": unique_rates, + "current_period": current_period, + "low_rate": round(low_rate / 100, 4), + "high_rate": round(high_rate / 100, 4), + "rate_slots": len(rates), + "warning": "Time-based rate selection not configured for this tariff" + } # Convert pence to pounds self._attr_native_value = round(unit_rate / 100, 4) if not self._attr_extra_state_attributes: diff --git a/custom_components/eon_next/tariff_patterns.py b/custom_components/eon_next/tariff_patterns.py new file mode 100644 index 0000000..98ad67a --- /dev/null +++ b/custom_components/eon_next/tariff_patterns.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +""" +Tariff time pattern configurations for different EON tariff types. + +The EON API only provides rate values without time metadata, so we need +to maintain hardcoded patterns for known tariffs. +""" + +# Tariff pattern definitions +# Each pattern defines time ranges and which rate index applies to each range + +TARIFF_PATTERNS = { + # Next Drive Smart: Off-Peak 00:00-07:00, Peak 07:00-24:00 + # Rate 0 = Off-Peak, Rate 1 = Peak + "next_drive": { + "type": "2-rate", + "off_peak_start": 0, # 00:00 + "off_peak_end": 7, # 07:00 + "rate_mapping": { + "off_peak": 0, + "peak": 1 + }, + "description": "Next Drive: Off-Peak 00:00-07:00, Peak 07:00-24:00" + }, + + # Economy 7: Typically 00:00-07:00 off-peak (varies by supplier) + "economy_7": { + "type": "2-rate", + "off_peak_start": 0, + "off_peak_end": 7, + "rate_mapping": { + "off_peak": 0, + "peak": 1 + }, + "description": "Economy 7: Off-Peak 00:00-07:00, Peak 07:00-24:00" + }, +} + + +def get_tariff_pattern(tariff_name: str) -> dict: + """ + Get the time pattern configuration for a tariff. + + Args: + tariff_name: The display name or tariff code + + Returns: + Pattern configuration dict or None if not found + """ + tariff_name_lower = tariff_name.lower() + tariff_name_upper = tariff_name.upper() + + # Check for specific tariff matches + if "next drive" in tariff_name_lower or "NEXT_DRIVE" in tariff_name_upper: + return TARIFF_PATTERNS["next_drive"] + elif "economy 7" in tariff_name_lower or "ECONOMY_7" in tariff_name_upper: + return TARIFF_PATTERNS["economy_7"] + + return None + + +def get_current_rate_index(pattern: dict, hour: int) -> int: + """ + Get the rate index for the current hour based on tariff pattern. + + Args: + pattern: Tariff pattern configuration + hour: Current hour (0-23) + + Returns: + Rate index (0 or 1 for 2-rate tariffs) + """ + if pattern["type"] != "2-rate": + return 0 # Default to first rate + + off_peak_start = pattern["off_peak_start"] + off_peak_end = pattern["off_peak_end"] + + # Check if current hour is in off-peak period + if off_peak_start <= hour < off_peak_end: + return pattern["rate_mapping"]["off_peak"] + else: + return pattern["rate_mapping"]["peak"] + + +def get_current_period_name(pattern: dict, hour: int) -> str: + """ + Get the period name (Off-Peak/Peak) for the current hour. + + Args: + pattern: Tariff pattern configuration + hour: Current hour (0-23) + + Returns: + Period name string + """ + if pattern["type"] != "2-rate": + return "Unknown" + + off_peak_start = pattern["off_peak_start"] + off_peak_end = pattern["off_peak_end"] + + if off_peak_start <= hour < off_peak_end: + return "Off-Peak" + else: + return "Peak" + + +def get_period_hours(pattern: dict) -> tuple: + """ + Get the off-peak and peak hour ranges as formatted strings. + + Returns: + Tuple of (off_peak_str, peak_str) + """ + off_peak_start = pattern["off_peak_start"] + off_peak_end = pattern["off_peak_end"] + + off_peak_str = f"{off_peak_start:02d}:00-{off_peak_end:02d}:00" + + # Calculate peak hours + if off_peak_end == 0: + peak_str = "00:00-23:59" + else: + peak_str = f"{off_peak_end:02d}:00-23:59" + + return off_peak_str, peak_str From ca5dced5fc43c4835d66a553f3a9bd4bee390a43 Mon Sep 17 00:00:00 2001 From: Steve Aitken Date: Thu, 30 Apr 2026 21:05:17 +0100 Subject: [PATCH 10/10] Fix Tesla smart charging schedule never updating after initial failure - always set last_updated, add error logging, retry with exponential backoff --- cheap_electric_heat_backup.yaml | 0 custom_components/eon_next/eonnext.py | 40 ++++++++--- example_automation.yaml | 26 ++++++++ lovelace_ha_eon_charging.json | 96 +++++++++++++++++++++++++++ 4 files changed, 151 insertions(+), 11 deletions(-) create mode 100644 cheap_electric_heat_backup.yaml create mode 100644 example_automation.yaml create mode 100644 lovelace_ha_eon_charging.json diff --git a/cheap_electric_heat_backup.yaml b/cheap_electric_heat_backup.yaml new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/eon_next/eonnext.py b/custom_components/eon_next/eonnext.py index 3e44172..af2e142 100644 --- a/custom_components/eon_next/eonnext.py +++ b/custom_components/eon_next/eonnext.py @@ -471,6 +471,7 @@ def __init__(self, account: EnergyAccount, meter_id: str, serial: str): super().__init__(account, meter_id, serial) self.type = METER_TYPE_EV self.schedule = None + self.failed_attempts = 0 def _should_update(self) -> bool: @@ -478,20 +479,37 @@ def _should_update(self) -> bool: return True now = datetime.datetime.now() - return (now - self.last_updated) >= datetime.timedelta(minutes=5) + # Retry sooner if we had failures (exponential backoff: 1-5 min based on failure count) + retry_minutes = min(1 + self.failed_attempts, 5) + return (now - self.last_updated) >= datetime.timedelta(minutes=retry_minutes) async def _update(self): - result = await self.api._graphql_post( - "getSmartChargingSchedule", - "query getSmartChargingSchedule($deviceId: String!) {\n flexPlannedDispatches(deviceId: $deviceId) {\n start\n end\n type\n energyAddedKwh\n }\n}\n", - { - "deviceId": self.meter_id - } - ) - - if self.api._json_contains_key_chain(result, ["data", "flexPlannedDispatches"]) == True: - self.schedule = result['data']['flexPlannedDispatches'] + try: + result = await self.api._graphql_post( + "getSmartChargingSchedule", + "query getSmartChargingSchedule($deviceId: String!) {\n flexPlannedDispatches(deviceId: $deviceId) {\n start\n end\n type\n energyAddedKwh\n }\n}\n", + { + "deviceId": self.meter_id + } + ) + + if self.api._json_contains_key_chain(result, ["data", "flexPlannedDispatches"]) == True: + new_schedule = result['data']['flexPlannedDispatches'] + if new_schedule != self.schedule: + _LOGGER.debug(f"SmartCharging schedule updated for {self.serial}: {len(new_schedule)} slots") + self.schedule = new_schedule + self.failed_attempts = 0 # Reset failure counter on success + else: + _LOGGER.warning(f"SmartCharging API response missing flexPlannedDispatches for {self.serial}: {result}") + self.schedule = [] + self.failed_attempts += 1 + except Exception as e: + _LOGGER.error(f"SmartCharging._update() failed for {self.serial}: {e}") + self.schedule = [] + self.failed_attempts += 1 + finally: + # Always update timestamp to prevent API hammering on failure self.last_updated = datetime.datetime.now() async def get_schedule(self): diff --git a/example_automation.yaml b/example_automation.yaml new file mode 100644 index 0000000..4c97abc --- /dev/null +++ b/example_automation.yaml @@ -0,0 +1,26 @@ +automation: + - alias: "Energy-Smart Device Control" + description: "Control hot water immersion and dehumidifier based on electricity rates" + trigger: + - platform: state + entity_id: sensor.account_XXXX_unit_rate # Replace XXXX with your account number + attribute: current_period + - platform: state + entity_id: sensor.account_XXXX_unit_rate + attribute: unit_rate + condition: + - condition: or + conditions: + - condition: state + entity_id: sensor.account_XXXX_unit_rate + attribute: current_period + state: "Off-Peak" + - condition: numeric_state + entity_id: sensor.account_XXXX_unit_rate + below: 0.15 # Adjust threshold as needed (GBP/kWh) + action: + - service: switch.turn_on + entity_id: switch.hot_water_immersion # Replace with your hot water switch + - service: switch.turn_on + entity_id: switch.dehumidifier # Replace with your dehumidifier switch + mode: single \ No newline at end of file diff --git a/lovelace_ha_eon_charging.json b/lovelace_ha_eon_charging.json new file mode 100644 index 0000000..766dacd --- /dev/null +++ b/lovelace_ha_eon_charging.json @@ -0,0 +1,96 @@ +{ + "title": "HA Eon charging", + "views": [ + { + "title": "Home", + "path": "home", + "icon": "mdi:ev-station", + "cards": [ + { + "type": "markdown", + "title": "Status now", + "content": "\n## EON / cheap-heat live status\n- **Cheap window:** {{ states('binary_sensor.cheap_electricity_window_active') }}\n- **Reason:** {{ state_attr('binary_sensor.cheap_electricity_window_active', 'reason') or 'n/a' }}\n- **Current rate:** {{ states('sensor.eon_next_current_rate') }} p/kWh\n- **Threshold:** {{ states('input_number.eon_cheap_rate_threshold') }} p/kWh\n- **Tesla schedule:** {{ states('sensor.tesla_model_3_smart_charging_schedule') }}\n- **Next slot:** {{ states('sensor.tesla_model_3_next_charge_start') }} → {{ states('sensor.tesla_model_3_next_charge_end') }}\n- **Hot water:** {{ states('water_heater.hot_water') }}\n- **Immersion:** {{ states('switch.sonoff_10015a9776') }}\n" + }, + { + "type": "entities", + "title": "Window and tariff", + "show_header_toggle": false, + "entities": [ + "input_boolean.enable_dynamic_cheap_charging", + "binary_sensor.cheap_electricity_window_active", + "input_boolean.eon_next_boost_session", + "sensor.smart_meter_current_rate", + "sensor.eon_next_current_rate", + "input_number.eon_cheap_rate_threshold" + ] + }, + { + "type": "entities", + "title": "EON Tariff Details", + "show_header_toggle": false, + "entities": [ + "sensor.eon_next_current_rate", + "sensor.account_a_d2bdacdf_unit_rate" + ] + }, + { + "type": "entities", + "title": "Tesla charging schedule", + "show_header_toggle": false, + "entities": [ + "sensor.tesla_model_3_smart_charging_schedule", + "sensor.tesla_model_3_next_charge_start", + "sensor.tesla_model_3_next_charge_end", + "sensor.tesla_model_3_next_charge_start_2", + "sensor.tesla_model_3_next_charge_end_2" + ] + }, + { + "type": "entities", + "title": "Hot water and electric heat", + "show_header_toggle": false, + "entities": [ + "water_heater.hot_water", + "input_boolean.cheap_heat_tado_water_pre_saved", + "switch.sonoff_10015a9776", + "automation.cheap_heat_hot_water_safety_auto_outside_window" + ] + }, + { + "type": "entities", + "title": "Tado gas zones", + "show_header_toggle": false, + "entities": [ + "climate.kitchen", + "climate.master_bathroom", + "climate.kids_bathroom" + ] + }, + { + "type": "entities", + "title": "UFH electric zones", + "show_header_toggle": false, + "entities": [ + "climate.kitchen_front", + "climate.smart_thermostat", + "climate.master_bathroom_2", + "climate.kids_bathroom_2", + "climate.toilet_downstairs" + ] + }, + { + "type": "entities", + "title": "Automation health", + "show_header_toggle": false, + "entities": [ + "automation.cheap_heat_pre_disable_tado_hot_water_before_tesla_slot", + "automation.cheap_heat_pre_disable_tado_hot_water_before_cheap_window", + "automation.cheap_heat_enable_electric_sources", + "automation.cheap_heat_restore_previous_sources", + "automation.cheap_heat_morning_safety_restore" + ] + } + ] + } + ] +}