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/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/__init__.py b/custom_components/eon_next/__init__.py index a4c35a0..5095141 100755 --- a/custom_components/eon_next/__init__.py +++ b/custom_components/eon_next/__init__.py @@ -18,12 +18,12 @@ 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 (use name-mangled private method) + await api._EonNext__init_accounts() + 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..af2e142 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,10 @@ 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() + await account._load_billing_data() self.accounts.append(account) @@ -196,8 +218,87 @@ 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.billing_data = [] + 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 validFrom validTo rateType } 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_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", + "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 +314,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 +463,56 @@ 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 + self.failed_attempts = 0 + + + def _should_update(self) -> bool: + if self.last_updated == None: + return True + + now = datetime.datetime.now() + # 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): + 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): + await self.update() + return self.schedule + diff --git a/custom_components/eon_next/sensor.py b/custom_components/eon_next/sensor.py index 1e2c4c5..ab6f8ff 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,8 @@ ) from . import DOMAIN -from .eonnext import METER_TYPE_GAS, METER_TYPE_ELECTRIC +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__) @@ -36,6 +38,26 @@ 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)) + + # Add billing sensors + entities.append(BillingHistorySensor(account)) async_add_entities(entities, update_before_add=True) @@ -113,3 +135,375 @@ 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'), + "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" + self._attr_extra_state_attributes = {} + + + 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 + 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 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": "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) + + 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: + 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": [] + } + + +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 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 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" + ] + } + ] + } + ] +}