diff --git a/.gitignore b/.gitignore index b2c602e..189511d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ notes -test.py \ No newline at end of file +test.py +documents/investigation_plan.md +.github/copilot-instructions.md diff --git a/README.md b/README.md index c2b034a..6cf519c 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,43 @@ # 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: + +- **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 + +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. -For electric meters the readings are in kWh. For gas meter the readings are in m³. +### Saving Sessions +If your account participates in saving sessions (similar to Octopus Energy's scheme): -An additional sensor is created for gas meters showing the latest reading in kWh. +- **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/__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..7abfd3c 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" @@ -19,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: @@ -92,18 +98,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 +203,9 @@ 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) @@ -196,8 +217,70 @@ class EnergyAccount: 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 = [] + 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!) { 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 + } + ) + + 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($postcode: String!) { appSessions(postcode: $postcode) { edges { node { id startedAt __typename } } } }", + { + "postcode": self.postcode + } + ) + + 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 = [] + + + 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", @@ -213,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']: @@ -361,3 +445,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..685dafd 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,23 @@ 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)) + + # 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) @@ -113,3 +131,297 @@ 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 + + +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') + + # 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) + 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: + 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 = [] + 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": [ + { + "id": s.get('id'), + "start": s.get('startedAt') or s.get('startAt'), + "type": s.get('type') + } + 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": [] + }