diff --git a/custom_components/octopus_energy/__init__.py b/custom_components/octopus_energy/__init__.py index a5bfcc5d..b5bfea07 100644 --- a/custom_components/octopus_energy/__init__.py +++ b/custom_components/octopus_energy/__init__.py @@ -22,16 +22,21 @@ from .statistics import get_statistic_ids_to_remove from .intelligent import get_intelligent_features, is_intelligent_product, mock_intelligent_device from .config.rolling_target_rates import async_migrate_rolling_target_config +from .coordinators.heatpump_configuration_and_status import HeatPumpCoordinatorResult, async_setup_heat_pump_coordinator from .config.main import async_migrate_main_config from .config.target_rates import async_migrate_target_config from .config.cost_tracker import async_migrate_cost_tracker_config from .utils import get_active_tariff -from .utils.debug_overrides import DebugOverride, async_get_debug_override +from .utils.debug_overrides import MeterDebugOverride, async_get_account_debug_override, async_get_meter_debug_override from .utils.error import api_exception_to_string from .storage.account import async_load_cached_account, async_save_cached_account from .storage.intelligent_device import async_load_cached_intelligent_device, async_save_cached_intelligent_device + +from .heat_pump import get_mock_heat_pump_id, mock_heat_pump_status_and_configuration +from .storage.heat_pump import async_load_cached_heat_pump, async_save_cached_heat_pump + from .const import ( CONFIG_FAVOUR_DIRECT_DEBIT_RATES, CONFIG_KIND, @@ -44,6 +49,7 @@ CONFIG_MAIN_HOME_PRO_API_KEY, CONFIG_MAIN_OLD_API_KEY, CONFIG_VERSION, + DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_KEY, DATA_HOME_PRO_CLIENT, DATA_INTELLIGENT_DEVICE, DATA_INTELLIGENT_MPAN, @@ -342,15 +348,13 @@ async def async_setup_dependencies(hass, config): has_intelligent_tariff = False intelligent_mpan = None intelligent_serial_number = None - debug_override: DebugOverride | None = None + account_debug_override = await async_get_account_debug_override(hass, account_id) for point in account_info["electricity_meter_points"]: mpan = point["mpan"] electricity_tariff = get_active_tariff(now, point["agreements"]) for meter in point["meters"]: serial_number = meter["serial_number"] - - debug_override = await async_get_debug_override(hass, mpan, serial_number) if electricity_tariff is not None: if meter["is_export"] == False: @@ -364,7 +368,7 @@ async def async_setup_dependencies(hass, config): if electricity_device is not None: device_registry.async_remove_device(electricity_device.id) - should_mock_intelligent_data = debug_override.mock_intelligent_controls if debug_override is not None else False + should_mock_intelligent_data = account_debug_override.mock_intelligent_controls if account_debug_override is not None else False if should_mock_intelligent_data: # Pick the first meter if we're mocking our intelligent data for point in account_info["electricity_meter_points"]: @@ -425,16 +429,38 @@ async def async_setup_dependencies(hass, config): serial_number = meter["serial_number"] is_export_meter = meter["is_export"] is_smart_meter = meter["is_smart_meter"] - override = await async_get_debug_override(hass, mpan, serial_number) + override = await async_get_meter_debug_override(hass, mpan, serial_number) tariff_override = override.tariff if override is not None else None planned_dispatches_supported = intelligent_features.planned_dispatches_supported if intelligent_features is not None else True await async_setup_electricity_rates_coordinator(hass, account_id, mpan, serial_number, is_smart_meter, is_export_meter, planned_dispatches_supported, tariff_override) + mock_heat_pump = account_debug_override.mock_heat_pump if account_debug_override is not None else False + if mock_heat_pump: + heat_pump_id = get_mock_heat_pump_id() + await async_setup_heat_pump_coordinator(hass, account_id, heat_pump_id, True) + + key = DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_KEY.format(heat_pump_id) + try: + hass.data[DOMAIN][account_id][key] = HeatPumpCoordinatorResult(now, 1, heat_pump_id, mock_heat_pump_status_and_configuration()) + await async_save_cached_heat_pump(hass, account_id, heat_pump_id, hass.data[DOMAIN][account_id][key].data) + except: + hass.data[DOMAIN][account_id][key] = HeatPumpCoordinatorResult(now, 1, heat_pump_id, await async_load_cached_heat_pump(hass, account_id, heat_pump_id)) + elif "heat_pump_ids" in account_info: + for heat_pump_id in account_info["heat_pump_ids"]: + await async_setup_heat_pump_coordinator(hass, account_id, heat_pump_id, False) + + key = DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_KEY.format(heat_pump_id) + try: + hass.data[DOMAIN][account_id][key] = HeatPumpCoordinatorResult(now, 1, heat_pump_id, await client.async_get_heatpump_configuration_and_status(account_id, heat_pump_id)) + await async_save_cached_heat_pump(hass, account_id, heat_pump_id, hass.data[DOMAIN][account_id][key].data) + except: + hass.data[DOMAIN][account_id][key] = HeatPumpCoordinatorResult(now, 1, heat_pump_id, await async_load_cached_heat_pump(hass, account_id, heat_pump_id)) + await async_setup_account_info_coordinator(hass, account_id) - await async_setup_intelligent_dispatches_coordinator(hass, account_id, debug_override.mock_intelligent_controls if debug_override is not None else False) + await async_setup_intelligent_dispatches_coordinator(hass, account_id, account_debug_override.mock_intelligent_controls if account_debug_override is not None else False) - await async_setup_intelligent_settings_coordinator(hass, account_id, intelligent_device.id if intelligent_device is not None else None, debug_override.mock_intelligent_controls if debug_override is not None else False) + await async_setup_intelligent_settings_coordinator(hass, account_id, intelligent_device.id if intelligent_device is not None else None, account_debug_override.mock_intelligent_controls if account_debug_override is not None else False) await async_setup_saving_sessions_coordinators(hass, account_id) diff --git a/custom_components/octopus_energy/const.py b/custom_components/octopus_energy/const.py index aefbd324..bc257ab8 100644 --- a/custom_components/octopus_energy/const.py +++ b/custom_components/octopus_energy/const.py @@ -16,6 +16,7 @@ REFRESH_RATE_IN_MINUTES_OCTOPLUS_POINTS = 60 REFRESH_RATE_IN_MINUTES_GREENNESS_FORECAST = 180 REFRESH_RATE_IN_MINUTES_HOME_PRO_CONSUMPTION = 0.17 +REFRESH_RATE_IN_MINUTES_HEAT_PUMP = 1 CONFIG_VERSION = 4 @@ -140,12 +141,16 @@ DATA_FREE_ELECTRICITY_SESSIONS = "FREE_ELECTRICITY_SESSIONS" DATA_FREE_ELECTRICITY_SESSIONS_COORDINATOR = "FREE_ELECTRICITY_SESSIONS_COORDINATOR" +DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_KEY = "HEAT_PUMP_CONFIGURATION_AND_STATUS_{}" +DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_COORDINATOR = "HEAT_PUMP_CONFIGURATION_AND_STATUS_COORDINATOR_{}" + DATA_SAVING_SESSIONS_FORCE_UPDATE = "SAVING_SESSIONS_FORCE_UPDATE" STORAGE_COMPLETED_DISPATCHES_NAME = "octopus_energy.{}-completed-intelligent-dispatches.json" STORAGE_ELECTRICITY_TARIFF_OVERRIDE_NAME = "octopus_energy.{}-{}-tariff-override.json" STORAGE_TARIFF_CACHE_NAME = "octopus_energy.tariff-{}.json" STORAGE_METER_DEBUG_OVERRIDE_NAME = "octopus_energy.{}-{}-override.json" +STORAGE_ACCOUNT_DEBUG_OVERRIDE_NAME = "octopus_energy.{}-override.json" INTELLIGENT_SOURCE_SMART_CHARGE = "smart-charge" INTELLIGENT_SOURCE_BUMP_CHARGE = "bump-charge" diff --git a/custom_components/octopus_energy/coordinators/heatpump_configuration_and_status.py b/custom_components/octopus_energy/coordinators/heatpump_configuration_and_status.py new file mode 100644 index 00000000..d7573b23 --- /dev/null +++ b/custom_components/octopus_energy/coordinators/heatpump_configuration_and_status.py @@ -0,0 +1,124 @@ +import logging +from datetime import datetime, timedelta + +from homeassistant.util.dt import (utcnow) +from homeassistant.helpers.update_coordinator import ( + DataUpdateCoordinator +) + +from ..const import ( + COORDINATOR_REFRESH_IN_SECONDS, + DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_COORDINATOR, + DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_KEY, + DOMAIN, + + DATA_CLIENT, + DATA_ACCOUNT, + DATA_ACCOUNT_COORDINATOR, + REFRESH_RATE_IN_MINUTES_HEAT_PUMP, +) + +from ..api_client import ApiException, OctopusEnergyApiClient +from ..api_client.heat_pump import HeatPumpResponse +from . import BaseCoordinatorResult + +from ..heat_pump import mock_heat_pump_status_and_configuration + +_LOGGER = logging.getLogger(__name__) + +class HeatPumpCoordinatorResult(BaseCoordinatorResult): + euid: str + data: HeatPumpResponse + + def __init__(self, last_evaluated: datetime, request_attempts: int, euid: str, data: HeatPumpResponse, last_error: Exception | None = None): + super().__init__(last_evaluated, request_attempts, REFRESH_RATE_IN_MINUTES_HEAT_PUMP, None, last_error) + self.euid = euid + self.data = data + +async def async_refresh_heat_pump_configuration_and_status( + current: datetime, + client: OctopusEnergyApiClient, + account_info, + euid: str, + existing_heat_pump_result: HeatPumpCoordinatorResult | None, + is_mocked: bool +): + if (account_info is not None): + account_id = account_info["id"] + if (existing_heat_pump_result is None or current >= existing_heat_pump_result.next_refresh): + status_and_configuration = None + raised_exception = None + + if is_mocked: + status_and_configuration = mock_heat_pump_status_and_configuration() + elif euid is not None: + try: + status_and_configuration = await client.async_get_heatpump_configuration_and_status(account_id, euid) + _LOGGER.debug(f'Heat Pump config and status retrieved for account {account_id} and device {euid}') + except Exception as e: + if isinstance(e, ApiException) == False: + raise + + raised_exception = e + _LOGGER.debug(f'Failed to retrieve heat pump configuration and status for account {account_id} and device {euid}') + + if status_and_configuration is not None: + return HeatPumpCoordinatorResult(current, 1, euid, status_and_configuration) + + result = None + if (existing_heat_pump_result is not None): + result = HeatPumpCoordinatorResult( + existing_heat_pump_result.last_evaluated, + existing_heat_pump_result.request_attempts + 1, + euid, + existing_heat_pump_result.data, + last_error=raised_exception + ) + + if (result.request_attempts == 2): + _LOGGER.warning(f"Failed to retrieve new heat pump configuration and status - using cached settings. See diagnostics sensor for more information.") + else: + # We want to force into our fallback mode + result = HeatPumpCoordinatorResult(current - timedelta(minutes=REFRESH_RATE_IN_MINUTES_HEAT_PUMP), 2, euid, None, last_error=raised_exception) + _LOGGER.warning(f"Failed to retrieve new heat pump configuration and status. See diagnostics sensor for more information.") + + return result + + return existing_heat_pump_result + +async def async_setup_heat_pump_coordinator(hass, account_id: str, euid: str, mock_heat_pump_data: bool): + key = DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_KEY.format(euid) + # Reset data as we might have new information + hass.data[DOMAIN][account_id][key] = None + + async def async_update_heat_pump_data(): + """Fetch data from API endpoint.""" + # Request our account data to be refreshed + account_coordinator = hass.data[DOMAIN][account_id][DATA_ACCOUNT_COORDINATOR] + if account_coordinator is not None: + await account_coordinator.async_request_refresh() + + current = utcnow() + client: OctopusEnergyApiClient = hass.data[DOMAIN][account_id][DATA_CLIENT] + account_result = hass.data[DOMAIN][account_id][DATA_ACCOUNT] + account_info = account_result.account if account_result is not None else None + + hass.data[DOMAIN][account_id][key] = await async_refresh_heat_pump_configuration_and_status( + current, + client, + account_info, + euid, + hass.data[DOMAIN][account_id][key] if key in hass.data[DOMAIN][account_id] else None, + mock_heat_pump_data + ) + + return hass.data[DOMAIN][account_id][key] + + hass.data[DOMAIN][account_id][DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_COORDINATOR.format(euid)] = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"heat_pump_{account_id}", + update_method=async_update_heat_pump_data, + update_interval=timedelta(seconds=COORDINATOR_REFRESH_IN_SECONDS), + always_update=True + ) \ No newline at end of file diff --git a/custom_components/octopus_energy/coordinators/intelligent_dispatches.py b/custom_components/octopus_energy/coordinators/intelligent_dispatches.py index 4017f535..ca2eb595 100644 --- a/custom_components/octopus_energy/coordinators/intelligent_dispatches.py +++ b/custom_components/octopus_energy/coordinators/intelligent_dispatches.py @@ -79,7 +79,7 @@ async def async_refresh_intelligent_dispatches( raise raised_exception=e - _LOGGER.debug('Failed to retrieve intelligent dispatches for account {account_id}') + _LOGGER.debug(f'Failed to retrieve intelligent dispatches for account {account_id}') if is_data_mocked: dispatches = mock_intelligent_dispatches() diff --git a/custom_components/octopus_energy/coordinators/intelligent_settings.py b/custom_components/octopus_energy/coordinators/intelligent_settings.py index c6a58edd..5c5ec7bc 100644 --- a/custom_components/octopus_energy/coordinators/intelligent_settings.py +++ b/custom_components/octopus_energy/coordinators/intelligent_settings.py @@ -56,7 +56,7 @@ async def async_refresh_intelligent_settings( raise raised_exception = e - _LOGGER.debug('Failed to retrieve intelligent settings for account {account_id}') + _LOGGER.debug(f'Failed to retrieve intelligent settings for account {account_id}') if is_settings_mocked: settings = mock_intelligent_settings() diff --git a/custom_components/octopus_energy/heat_pump/__init__.py b/custom_components/octopus_energy/heat_pump/__init__.py index f5feef31..003b52da 100644 --- a/custom_components/octopus_energy/heat_pump/__init__.py +++ b/custom_components/octopus_energy/heat_pump/__init__.py @@ -1,5 +1,8 @@ +import random from ..api_client.heat_pump import HeatPumpResponse +def get_mock_heat_pump_id(): + return "ABC" def mock_heat_pump_status_and_configuration(): data = { @@ -12,7 +15,7 @@ def mock_heat_pump_status_and_configuration(): "retrievedAt": "2024-12-01T10:04:54.952000+00:00" }, "telemetry": { - "temperatureInCelsius": 57.4, + "temperatureInCelsius": 57 + (random.randrange(1, 20) * 0.1), "humidityPercentage": None, "retrievedAt": "2024-12-01T10:04:51.588000+00:00" } @@ -24,7 +27,7 @@ def mock_heat_pump_status_and_configuration(): "retrievedAt": "2024-12-01T10:04:54.952000+00:00" }, "telemetry": { - "temperatureInCelsius": -273.1, + "temperatureInCelsius": -273 + (random.randrange(1, 20) * 0.1), "humidityPercentage": None, "retrievedAt": "2024-12-01T10:04:51.588000+00:00" } @@ -36,7 +39,7 @@ def mock_heat_pump_status_and_configuration(): "retrievedAt": "2024-12-01T10:04:54.953000+00:00" }, "telemetry": { - "temperatureInCelsius": -273.1, + "temperatureInCelsius": -273 + (random.randrange(1, 20) * 0.1), "humidityPercentage": None, "retrievedAt": "2024-12-01T10:04:51.588000+00:00" } @@ -48,7 +51,7 @@ def mock_heat_pump_status_and_configuration(): "retrievedAt": "2024-12-01T10:04:54.953000+00:00" }, "telemetry": { - "temperatureInCelsius": -273.1, + "temperatureInCelsius": -273 + (random.randrange(1, 20) * 0.1), "humidityPercentage": None, "retrievedAt": "2024-12-01T10:04:51.588000+00:00" } @@ -60,8 +63,8 @@ def mock_heat_pump_status_and_configuration(): "retrievedAt": "2024-12-01T10:04:54.953000+00:00" }, "telemetry": { - "temperatureInCelsius": 19.4, - "humidityPercentage": 57, + "temperatureInCelsius": 18 + (random.randrange(1, 20) * 0.1), + "humidityPercentage": 57 + (random.randrange(1, 20) * 0.1), "retrievedAt": "2024-12-01T10:03:15.615000+00:00" } }, @@ -72,8 +75,8 @@ def mock_heat_pump_status_and_configuration(): "retrievedAt": "2024-12-01T10:04:54.955000+00:00" }, "telemetry": { - "temperatureInCelsius": 22.4, - "humidityPercentage": 54, + "temperatureInCelsius": 22 + (random.randrange(1, 20) * 0.1), + "humidityPercentage": 54 + (random.randrange(1, 20) * 0.1), "retrievedAt": "2024-12-01T10:03:54.876000+00:00" } }, @@ -84,8 +87,8 @@ def mock_heat_pump_status_and_configuration(): "retrievedAt": "2024-12-01T10:04:54.956000+00:00" }, "telemetry": { - "temperatureInCelsius": 22.3, - "humidityPercentage": 60, + "temperatureInCelsius": 22 + (random.randrange(1, 20) * 0.1), + "humidityPercentage": 60 + (random.randrange(1, 20) * 0.1), "retrievedAt": "2024-12-01T10:04:27.571000+00:00" } }, @@ -96,8 +99,8 @@ def mock_heat_pump_status_and_configuration(): "retrievedAt": "2024-12-01T10:04:54.957000+00:00" }, "telemetry": { - "temperatureInCelsius": 22.7, - "humidityPercentage": 46, + "temperatureInCelsius": 22 + (random.randrange(1, 20) * 0.1), + "humidityPercentage": 46 + (random.randrange(1, 20) * 0.1), "retrievedAt": "2024-12-01T10:03:12.376000+00:00" } } @@ -162,7 +165,7 @@ def mock_heat_pump_status_and_configuration(): "minWaterSetpoint": 40, "heatingFlowTemperature": { "currentTemperature": { - "value": "70", + "value": "56", "unit": "DEGREES_CELSIUS" }, "allowableRange": { diff --git a/custom_components/octopus_energy/heat_pump/base.py b/custom_components/octopus_energy/heat_pump/base.py new file mode 100644 index 00000000..17a6cd81 --- /dev/null +++ b/custom_components/octopus_energy/heat_pump/base.py @@ -0,0 +1,29 @@ +from homeassistant.core import HomeAssistant + +from homeassistant.helpers.entity import generate_entity_id, DeviceInfo + +from ..const import ( + DOMAIN, +) +from ..api_client.heat_pump import HeatPump + +class OctopusEnergyHeatPumpSensor: + _unrecorded_attributes = frozenset({"data_last_retrieved"}) + + def __init__(self, hass: HomeAssistant, heat_pump_id: str, heat_pump: HeatPump, entity_domain = "sensor"): + """Init sensor""" + self._heat_pump = heat_pump + self._heat_pump_id = heat_pump_id + + self._attributes = { + } + + self.entity_id = generate_entity_id(entity_domain + ".{}", self.unique_id, hass=hass) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"heat_pump_{heat_pump.serialNumber}")}, + name=f"Heat Pump ({heat_pump.serialNumber})", + connections=set(), + model=heat_pump.model, + hw_version=heat_pump.hardwareVersion + ) \ No newline at end of file diff --git a/custom_components/octopus_energy/heat_pump/sensor_temperature.py b/custom_components/octopus_energy/heat_pump/sensor_temperature.py new file mode 100644 index 00000000..9d05adb2 --- /dev/null +++ b/custom_components/octopus_energy/heat_pump/sensor_temperature.py @@ -0,0 +1,118 @@ +import logging +from typing import List + +from custom_components.octopus_energy.coordinators.heatpump_configuration_and_status import HeatPumpCoordinatorResult +from homeassistant.const import ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + UnitOfTemperature +) +from homeassistant.core import HomeAssistant, callback + +from homeassistant.util.dt import (now) +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity +) +from homeassistant.components.sensor import ( + RestoreSensor, + SensorDeviceClass, + SensorStateClass, +) + +from .base import (OctopusEnergyHeatPumpSensor) +from ..utils.attributes import dict_to_typed_dict +from ..api_client.heat_pump import HeatPump, Sensor, SensorConfiguration + +_LOGGER = logging.getLogger(__name__) + +class OctopusEnergyHeatPumpSensorTemperature(CoordinatorEntity, OctopusEnergyHeatPumpSensor, RestoreSensor): + """Sensor for displaying the temperature of a heat pump sensor.""" + + def __init__(self, hass: HomeAssistant, coordinator, heat_pump_id: str, heat_pump: HeatPump, sensor: SensorConfiguration): + """Init sensor.""" + # Pass coordinator to base class + CoordinatorEntity.__init__(self, coordinator) + + self._sensor = sensor + + OctopusEnergyHeatPumpSensor.__init__(self, hass, heat_pump_id, heat_pump) + + self._state = None + self._last_updated = None + + self._attributes = { + } + + @property + def unique_id(self): + """The id of the sensor.""" + return f"octopus_energy_heat_pump_{self._heat_pump_id}_{self._sensor.code}_temperature" + + @property + def name(self): + """Name of the sensor.""" + return f"Temperature ({self._sensor.displayName}) Heat Pump ({self._heat_pump_id})" + + @property + def state_class(self): + """The state class of sensor""" + return SensorStateClass.MEASUREMENT + + @property + def device_class(self): + """The type of sensor""" + return SensorDeviceClass.TEMPERATURE + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:thermometer" + + @property + def native_unit_of_measurement(self): + """Unit of measurement of the sensor.""" + return UnitOfTemperature.CELSIUS + + @property + def extra_state_attributes(self): + """Attributes of the sensor.""" + return self._attributes + + @property + def native_value(self): + return self._state + + @callback + def _handle_coordinator_update(self) -> None: + """Retrieve the previous rate.""" + # Find the previous rate. We only need to do this every half an hour + current = now() + result: HeatPumpCoordinatorResult = self.coordinator.data if self.coordinator is not None and self.coordinator.data is not None else None + if (result is not None and + result.data is not None and + result.data.octoHeatPumpControllerStatus is not None and + result.data.octoHeatPumpControllerStatus.sensors): + _LOGGER.debug(f"Updating OctopusEnergyHeatPumpSensorTemperature for '{self._heat_pump_id}/{self._sensor.code}'") + + self._state = None + sensors: List[Sensor] = result.data.octoHeatPumpControllerStatus.sensors + for sensor in sensors: + if sensor.code == self._sensor.code and sensor.telemetry is not None: + self._state = sensor.telemetry.temperatureInCelsius + + self._last_updated = current + + self._attributes = dict_to_typed_dict(self._attributes) + super()._handle_coordinator_update() + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + # If not None, we got an initial value. + await super().async_added_to_hass() + state = await self.async_get_last_state() + + if state is not None and self._state is None: + self._state = None if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) else state.state + self._attributes = dict_to_typed_dict(state.attributes, []) + + _LOGGER.debug(f'Restored OctopusEnergyHeatPumpSensorTemperature state: {self._state}') \ No newline at end of file diff --git a/custom_components/octopus_energy/manifest.json b/custom_components/octopus_energy/manifest.json index 00c5038f..6ad9a7a5 100644 --- a/custom_components/octopus_energy/manifest.json +++ b/custom_components/octopus_energy/manifest.json @@ -16,5 +16,5 @@ "ssdp": [], "version": "13.2.1", "zeroconf": [], - "requirements": ["pydantic==2.10.2"] + "requirements": ["pydantic"] } \ No newline at end of file diff --git a/custom_components/octopus_energy/sensor.py b/custom_components/octopus_energy/sensor.py index 0573e8fd..749313ae 100644 --- a/custom_components/octopus_energy/sensor.py +++ b/custom_components/octopus_energy/sensor.py @@ -1,5 +1,4 @@ from datetime import timedelta -from custom_components.octopus_energy.api_client.intelligent_device import IntelligentDevice import voluptuous as vol import logging @@ -58,7 +57,7 @@ from .diagnostics_entities.intelligent_settings_data_last_retrieved import OctopusEnergyIntelligentSettingsDataLastRetrieved from .diagnostics_entities.free_electricity_sessions_data_last_retrieved import OctopusEnergyFreeElectricitySessionsDataLastRetrieved -from .utils.debug_overrides import async_get_debug_override +from .utils.debug_overrides import AccountDebugOverride, async_get_account_debug_override, async_get_meter_debug_override from .coordinators.current_consumption import async_create_current_consumption_coordinator from .coordinators.gas_rates import async_setup_gas_rates_coordinator @@ -68,6 +67,11 @@ from .coordinators.wheel_of_fortune import async_setup_wheel_of_fortune_spins_coordinator from .coordinators.current_consumption_home_pro import async_create_home_pro_current_consumption_coordinator +from .api_client.heat_pump import HeatPumpResponse +from .api_client.intelligent_device import IntelligentDevice +from .heat_pump import get_mock_heat_pump_id +from .heat_pump.sensor_temperature import OctopusEnergyHeatPumpSensorTemperature + from .api_client import OctopusEnergyApiClient from .utils.tariff_cache import async_get_cached_tariff_total_unique_rates, async_save_cached_tariff_total_unique_rates from .utils.rate_information import get_peak_type, get_unique_rates, has_peak_rates @@ -92,6 +96,8 @@ DATA_FREE_ELECTRICITY_SESSIONS_COORDINATOR, DATA_ACCOUNT_COORDINATOR, DATA_GREENNESS_FORECAST_COORDINATOR, + DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_COORDINATOR, + DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_KEY, DATA_HOME_PRO_CLIENT, DATA_INTELLIGENT_DEVICE, DATA_INTELLIGENT_DISPATCHES_COORDINATOR, @@ -294,6 +300,8 @@ async def async_setup_default_sensors(hass: HomeAssistant, config, async_add_ent entities.append(OctopusEnergyOctoplusPoints(hass, client, account_id)) entities.append(OctopusEnergyFreeElectricitySessionsDataLastRetrieved(hass, free_electricity_session_coordinator, account_id)) + account_debug_override = await async_get_account_debug_override(hass, account_id) + now = utcnow() if len(account_info["electricity_meter_points"]) > 0: @@ -321,7 +329,7 @@ async def async_setup_default_sensors(hass: HomeAssistant, config, async_add_ent entities.append(OctopusEnergyCurrentRatesDataLastRetrieved(hass, electricity_rate_coordinator, True, meter, point)) entities.append(OctopusEnergyCurrentStandingChargeDataLastRetrieved(hass, electricity_standing_charges_coordinator, True, meter, point)) - debug_override = await async_get_debug_override(hass, mpan, serial_number) + debug_override = await async_get_meter_debug_override(hass, mpan, serial_number) previous_consumption_coordinator = await async_create_previous_consumption_and_rates_coordinator( hass, account_id, @@ -334,11 +342,11 @@ async def async_setup_default_sensors(hass: HomeAssistant, config, async_add_ent ) entities.append(OctopusEnergyPreviousAccumulativeElectricityConsumption(hass, client, previous_consumption_coordinator, account_id, meter, point)) entities.append(OctopusEnergyPreviousAccumulativeElectricityCost(hass, previous_consumption_coordinator, meter, point)) - entities.append(OctopusEnergySavingSessionBaseline(hass, saving_session_coordinator, previous_consumption_coordinator, meter, point, debug_override.mock_saving_session_baseline if debug_override is not None else False)) + entities.append(OctopusEnergySavingSessionBaseline(hass, saving_session_coordinator, previous_consumption_coordinator, meter, point, account_debug_override.mock_saving_session_baseline if debug_override is not None else False)) entities.append(OctopusEnergyPreviousConsumptionAndRatesDataLastRetrieved(hass, previous_consumption_coordinator, True, meter, point)) if octoplus_enrolled: - entities.append(OctopusEnergyFreeElectricitySessionBaseline(hass, free_electricity_session_coordinator, previous_consumption_coordinator, meter, point, debug_override.mock_saving_session_baseline if debug_override is not None else False)) + entities.append(OctopusEnergyFreeElectricitySessionBaseline(hass, free_electricity_session_coordinator, previous_consumption_coordinator, meter, point, account_debug_override.mock_saving_session_baseline if debug_override is not None else False)) # Create a peak override for each available peak type for our tariff total_unique_rates = await get_unique_electricity_rates(hass, client, electricity_tariff if debug_override is None or debug_override.tariff is None else debug_override.tariff) @@ -428,7 +436,7 @@ async def async_setup_default_sensors(hass: HomeAssistant, config, async_add_ent entities.append(OctopusEnergyCurrentRatesDataLastRetrieved(hass, gas_rate_coordinator, False, meter, point)) entities.append(OctopusEnergyCurrentStandingChargeDataLastRetrieved(hass, gas_standing_charges_coordinator, False, meter, point)) - debug_override = await async_get_debug_override(hass, mprn, serial_number) + debug_override = await async_get_meter_debug_override(hass, mprn, serial_number) previous_consumption_coordinator = await async_create_previous_consumption_and_rates_coordinator( hass, account_id, @@ -496,6 +504,18 @@ async def async_setup_default_sensors(hass: HomeAssistant, config, async_add_ent else: _LOGGER.info('No gas meters available') + mock_heat_pump = account_debug_override.mock_heat_pump if account_debug_override is not None else False + if mock_heat_pump: + heat_pump_id = get_mock_heat_pump_id() + key = DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_KEY.format(heat_pump_id) + coordinator = hass.data[DOMAIN][account_id][DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_COORDINATOR.format(heat_pump_id)] + entities.extend(setup_heat_pump_sensors(hass, heat_pump_id, hass.data[DOMAIN][account_id][key].data, coordinator)) + elif "heat_pump_ids" in account_info: + for heat_pump_id in account_info["heat_pump_ids"]: + key = DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_KEY.format(heat_pump_id) + coordinator = hass.data[DOMAIN][account_id][DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_COORDINATOR.format(heat_pump_id)] + entities.extend(setup_heat_pump_sensors(hass, heat_pump_id, hass.data[DOMAIN][account_id][key].data, coordinator)) + # Migrate entity ids that might have changed # for item in entity_ids_to_migrate: # entity_id = registry.async_get_entity_id("sensor", DOMAIN, item["old"]) @@ -508,6 +528,25 @@ async def async_setup_default_sensors(hass: HomeAssistant, config, async_add_ent async_add_entities(entities) +def setup_heat_pump_sensors(hass: HomeAssistant, heat_pump_id: str, heat_pump_response: HeatPumpResponse, coordinator): + + entities = [] + + if heat_pump_response is not None and heat_pump_response.octoHeatPumpControllerConfiguration is not None: + for zone in heat_pump_response.octoHeatPumpControllerConfiguration.zones: + if zone.configuration is not None and zone.configuration.sensors is not None: + for sensor in zone.configuration.sensors: + entities.append(OctopusEnergyHeatPumpSensorTemperature( + hass, + coordinator, + heat_pump_id, + heat_pump_response.octoHeatPumpControllerConfiguration.heatPump, + sensor + )) + + + return entities + async def async_setup_cost_sensors(hass: HomeAssistant, entry, config, async_add_entities): account_id = config[CONFIG_ACCOUNT_ID] account_result = hass.data[DOMAIN][account_id][DATA_ACCOUNT] @@ -552,7 +591,7 @@ async def async_setup_cost_sensors(hass: HomeAssistant, entry, config, async_add OctopusEnergyCostTrackerMonthSensor(hass, entry, config, device_entry, sensor_entity_id if sensor_entity_id is not None else sensor.entity_id), ] - debug_override = await async_get_debug_override(hass, mpan, serial_number) + debug_override = await async_get_meter_debug_override(hass, mpan, serial_number) total_unique_rates = await get_unique_electricity_rates(hass, client, tariff if debug_override is None or debug_override.tariff is None else debug_override.tariff) if has_peak_rates(total_unique_rates): for unique_rate_index in range(0, total_unique_rates): diff --git a/custom_components/octopus_energy/storage/heat_pump.py b/custom_components/octopus_energy/storage/heat_pump.py new file mode 100644 index 00000000..4126b041 --- /dev/null +++ b/custom_components/octopus_energy/storage/heat_pump.py @@ -0,0 +1,23 @@ +import logging +from homeassistant.helpers import storage + +from ..api_client.heat_pump import HeatPumpResponse + +_LOGGER = logging.getLogger(__name__) + +async def async_load_cached_heat_pump(hass, account_id: str, euid: str) -> HeatPumpResponse: + store = storage.Store(hass, "1", f"octopus_energy.{account_id}_{euid}_heat_pump") + + try: + data = await store.async_load() + if data is not None: + _LOGGER.debug(f"Loaded cached intelligent device data for {account_id}/{euid}") + return HeatPumpResponse.parse_obj(data) + except: + return None + +async def async_save_cached_heat_pump(hass, account_id: str, euid: str, heat_pump: HeatPumpResponse): + if heat_pump is not None: + store = storage.Store(hass, "1", f"octopus_energy.{account_id}_{euid}_heat_pump") + await store.async_save(heat_pump.dict()) + _LOGGER.debug(f"Saved heat pymp data for {account_id}/{euid}") \ No newline at end of file diff --git a/custom_components/octopus_energy/storage/intelligent_device.py b/custom_components/octopus_energy/storage/intelligent_device.py index 8c975ff8..5c4ba2f5 100644 --- a/custom_components/octopus_energy/storage/intelligent_device.py +++ b/custom_components/octopus_energy/storage/intelligent_device.py @@ -12,15 +12,15 @@ async def async_load_cached_intelligent_device(hass, account_id: str) -> Intelli data = await store.async_load() if data is not None: _LOGGER.debug(f"Loaded cached intelligent device data for {account_id}") - return IntelligentDevice( - data["id"], - data["provider"], - data["make"], - data["model"], - data["vehicleBatterySizeInKwh"], - data["chargePointPowerInKw"], - data["is_charger"] - ) + return IntelligentDevice( + data["id"], + data["provider"], + data["make"], + data["model"], + data["vehicleBatterySizeInKwh"], + data["chargePointPowerInKw"], + data["is_charger"] + ) except: return None diff --git a/custom_components/octopus_energy/utils/debug_overrides.py b/custom_components/octopus_energy/utils/debug_overrides.py index eccac6c1..60c25e1e 100644 --- a/custom_components/octopus_energy/utils/debug_overrides.py +++ b/custom_components/octopus_energy/utils/debug_overrides.py @@ -2,32 +2,57 @@ from homeassistant.helpers import storage -from ..const import STORAGE_METER_DEBUG_OVERRIDE_NAME +from ..const import STORAGE_ACCOUNT_DEBUG_OVERRIDE_NAME, STORAGE_METER_DEBUG_OVERRIDE_NAME from ..utils import Tariff _LOGGER = logging.getLogger(__name__) -class DebugOverride(): +class MeterDebugOverride(): - def __init__(self, tariff: Tariff, mock_intelligent_controls: bool, mock_saving_session_baseline: bool): + def __init__(self, tariff: Tariff): self.tariff = tariff + +async def async_get_meter_debug_override(hass, mpan_mprn: str, serial_number: str) -> MeterDebugOverride | None: + storage_key = STORAGE_METER_DEBUG_OVERRIDE_NAME.format(mpan_mprn, serial_number) + store = storage.Store(hass, "1", storage_key) + + try: + data = await store.async_load() + if data is not None: + debug = MeterDebugOverride( + Tariff(data["product_code"], data["tariff_code"]) if "tariff_code" in data and "product_code" in data else None + ) + + _LOGGER.info(f"Debug overrides discovered {mpan_mprn}/{serial_number} - {debug}") + + return debug + + except: + return None + + return None + +class AccountDebugOverride(): + + def __init__(self, mock_intelligent_controls: bool, mock_saving_session_baseline: bool, mock_heat_pump: bool): self.mock_intelligent_controls = mock_intelligent_controls self.mock_saving_session_baseline = mock_saving_session_baseline + self.mock_heat_pump = mock_heat_pump -async def async_get_debug_override(hass, mpan_mprn: str, serial_number: str) -> DebugOverride | None: - storage_key = STORAGE_METER_DEBUG_OVERRIDE_NAME.format(mpan_mprn, serial_number) +async def async_get_account_debug_override(hass, account_id: str) -> AccountDebugOverride | None: + storage_key = STORAGE_ACCOUNT_DEBUG_OVERRIDE_NAME.format(account_id) store = storage.Store(hass, "1", storage_key) try: data = await store.async_load() if data is not None: - debug = DebugOverride( - Tariff(data["product_code"], data["tariff_code"]) if "tariff_code" in data and "product_code" in data else None, + debug = AccountDebugOverride( data["mock_intelligent_controls"] == True if "mock_intelligent_controls" in data else False, data["mock_saving_session_baseline"] == True if "mock_saving_session_baseline" in data else False, + data["mock_heat_pump"] == True if "mock_heat_pump" in data else False, ) - _LOGGER.info(f"Debug overrides discovered {mpan_mprn}/{serial_number} - {debug}") + _LOGGER.info(f"Debug overrides discovered {account_id} - {debug}") return debug diff --git a/requirements.txt b/requirements.txt index 70437c39..f1a3e0c8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ mkdocs-material==9.5.27 mike==2.0.0 # mkdocs-git-committers-plugin-2==2.3.0 -mkdocs-git-authors-plugin==0.9.0 -pydantic==2.10.2 \ No newline at end of file +mkdocs-git-authors-plugin==0.9.0 \ No newline at end of file