From 11cc47c70a9e35f6d9a848b40afdee67506fb683 Mon Sep 17 00:00:00 2001 From: David Kendall Date: Fri, 31 Jan 2025 00:31:02 +0000 Subject: [PATCH] feat: Added new service for refreshing intelligent dispatches. This has some caveats, so please review the docs (4 hours 45 minutes dev time) --- _docs/entities/heat_pump.md | 4 + _docs/entities/octoplus.md | 4 +- _docs/entities/wheel_of_fortune.md | 2 +- _docs/events.md | 2 +- _docs/services.md | 176 +++++++++++------- _docs/setup/account.md | 4 + _docs/setup/cost_tracker.md | 2 +- _docs/setup/rolling_target_rate.md | 2 +- _docs/setup/target_rate.md | 2 +- custom_components/octopus_energy/__init__.py | 34 +++- .../octopus_energy/api_client/__init__.py | 2 + .../octopus_energy/binary_sensor.py | 15 ++ .../octopus_energy/config_flow.py | 9 +- custom_components/octopus_energy/const.py | 6 +- .../coordinators/intelligent_dispatches.py | 87 +++++++-- ...elligent_dispatches_data_last_retrieved.py | 25 ++- custom_components/octopus_energy/icons.json | 3 +- .../octopus_energy/intelligent/dispatching.py | 17 +- .../octopus_energy/services.yaml | 10 +- .../octopus_energy/translations/en.json | 16 +- ...st_async_refresh_electricity_rates_data.py | 42 ++--- ...st_async_refresh_intelligent_dispatches.py | 156 ++++++++++++---- 22 files changed, 449 insertions(+), 171 deletions(-) diff --git a/_docs/entities/heat_pump.md b/_docs/entities/heat_pump.md index e42c8a6c..5d027667 100644 --- a/_docs/entities/heat_pump.md +++ b/_docs/entities/heat_pump.md @@ -87,3 +87,7 @@ This represents the current outdoor temperature as observed by the heat pump. !!! note As the integration uses cloud polling this will inherently have a delay. + +## Services + +There are some services available relating to these entities that you might find useful. They can be found in the [services docs](../services.md#heat-pump). \ No newline at end of file diff --git a/_docs/entities/octoplus.md b/_docs/entities/octoplus.md index e4379aa8..dcd43ad6 100644 --- a/_docs/entities/octoplus.md +++ b/_docs/entities/octoplus.md @@ -51,7 +51,7 @@ Each available event item will include the following attributes | Attribute | Type | Description | |-----------|------|-------------| | `id` | `integer` | The id of the event | -| `code` | `string` | The event code of the event. This will be required to join via the [join service](../services.md) | +| `code` | `string` | The event code of the event. This will be required to join via the [join service](../services.md#octopus_energyjoin_octoplus_saving_session_event) | | `start` | `datetime` | The date/time the event starts | | `end` | `datetime` | The date/time the event starts | | `duration_in_minutes` | `integer` | The duration of the event in minutes | @@ -208,5 +208,5 @@ Each item within `baselines` consists of the following attributes ## Services -There are some services available relating to these entities that you might find useful. They can be found in the [services docs](../services.md). +There are some services available relating to these entities that you might find useful. They can be found in the [services docs](../services.md#octoplus). diff --git a/_docs/entities/wheel_of_fortune.md b/_docs/entities/wheel_of_fortune.md index 773d6516..98cad066 100644 --- a/_docs/entities/wheel_of_fortune.md +++ b/_docs/entities/wheel_of_fortune.md @@ -16,4 +16,4 @@ The number of spins remaining for gas supply ## Services -There are some services available relating to these entities that you might find useful. They can be found in the [services docs](../services.md). \ No newline at end of file +There are some services available relating to these entities that you might find useful. They can be found in the [services docs](../services.md#wheel-of-fortune). \ No newline at end of file diff --git a/_docs/events.md b/_docs/events.md index 0132367f..bf5f2068 100644 --- a/_docs/events.md +++ b/_docs/events.md @@ -298,7 +298,7 @@ Each available event item will include the following attributes | Attribute | Type | Description | |-----------|------|-------------| | `id` | `integer` | The id of the event | -| `code` | `string` | The event code of the event. This will be required to join via the [join service](./services.md) | +| `code` | `string` | The event code of the event. This will be required to join via the [join service](./services.md#octopus_energyjoin_octoplus_saving_session_event) | | `start` | `datetime` | The date/time the event starts | | `end` | `datetime` | The date/time the event starts | | `duration_in_minutes` | `integer` | The duration of the event in minutes | diff --git a/_docs/services.md b/_docs/services.md index f38c46f9..898d4a9d 100644 --- a/_docs/services.md +++ b/_docs/services.md @@ -2,20 +2,9 @@ There are a few services available within this integration, which are detailed here. -## octopus_energy.purge_invalid_external_statistic_ids +## Target Rates -For removing all external statistics that are associated with meters that don't have an active tariff. This is useful if you've been using the integration and obtained new smart meters. - -## octopus_energy.refresh_previous_consumption_data - -For refreshing the consumption/cost information for a given previous consumption entity. This is useful when you've just installed the integration and want old data brought in or a previous consumption sensor fails to import (e.g. data becomes available outside of the configured offset). The service will raise a notification when the refreshing starts and finishes. - -This service is only available for the following sensors - -- `sensor.octopus_energy_electricity_{{METER_SERIAL_NUMBER}}_{{MPAN_NUMBER}}_previous_accumulative_consumption` (this will populate both consumption and cost) -- `sensor.octopus_energy_gas_{{METER_SERIAL_NUMBER}}_{{MPRN_NUMBER}}_previous_accumulative_consumption_m3` (this will populate both consumption and cost for both m3 and kwh) - -## octopus_energy.update_target_config +### octopus_energy.update_target_config For updating a given [target rate's](./setup/target_rate.md) config. This allows you to change target rates sensors dynamically based on other outside criteria (e.g. you need to adjust the target hours to top up home batteries). @@ -31,7 +20,7 @@ For updating a given [target rate's](./setup/target_rate.md) config. This allows | `data.target_weighting` | `yes` | The optional weighting that should be applied to the selected rates. | | `data.persist_changes` | `yes` | Determines if the changes should be persisted to the original configuration or should be temporary and reset upon integration reload. If not supplied, then the changes are temporary | -### Automation Example +#### Automation Example This can be used via automations in the following way. Assuming we have the following inputs. @@ -83,7 +72,9 @@ action: entity_id: binary_sensor.octopus_energy_target_example ``` -## octopus_energy.update_rolling_target_config +## Rolling Target Rates + +### octopus_energy.update_rolling_target_config For updating a given [rolling target rate's](./setup/rolling_target_rate.md) config. This allows you to change rolling target rates sensors dynamically based on other outside criteria (e.g. you need to adjust the target hours to top up home batteries). @@ -98,7 +89,7 @@ For updating a given [rolling target rate's](./setup/rolling_target_rate.md) con | `data.target_weighting` | `yes` | The optional weighting that should be applied to the selected rates. | | `data.persist_changes` | `yes` | Determines if the changes should be persisted to the original configuration or should be temporary and reset upon integration reload. If not supplied, then the changes are temporary | -### Automation Example +#### Automation Example This can be used via automations in the following way. Assuming we have the following inputs. @@ -144,7 +135,9 @@ action: entity_id: binary_sensor.octopus_energy_rolling_target_example ``` -## octopus_energy.join_octoplus_saving_session_event +## Octoplus + +### octopus_energy.join_octoplus_saving_session_event Service for joining a new saving session event. When used, it may take a couple of minutes for the other sensors to refresh the changes. @@ -153,43 +146,47 @@ Service for joining a new saving session event. When used, it may take a couple | `target.entity_id` | `no` | The name of the target sensor whose configuration is to be updated. This should always point at the [saving session events](./entities/octoplus.md#saving-session-events) entity. | | `data.event_code` | `no` | The code of the event to join | -### Automation Example +#### Automation Example For an automation example, please refer to the available [blueprint](./blueprints.md#automatically-join-saving-sessions). -## octopus_energy.spin_wheel_of_fortune +### octopus_energy.redeem_octoplus_points_into_account_credit -This service allows the user to perform a spin on the [wheel of fortune](./entities/wheel_of_fortune.md) that is awarded to users every month. No point letting them go to waste :) - -!!! warning +Allows you to redeem a certain number of of Octoplus points and convert them into account credit. - Due to an ongoing issue with the underlying API, this will not award octopoints if used. If you are on Octoplus, it is advised not to use this service. +!!! info + This service is only available if you have signed up to Octoplus | Attribute | Optional | Description | | ------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------- | -| `target.entity_id` | `no` | The name of the wheel of fortune sensor that represents the type of spin to be made. This should always point at one of the [wheel of fortune sensors](./entities/wheel_of_fortune.md) entities. | +| `target.entity_id` | `no` | The name of the Octoplus points that hold the points to be redeemed. This should always point at one of the [octoplus points sensor](./entities/octoplus.md#octoplus-points) entities. | +| `data.points_to_redeem` | `no` | The number of points to redeem. | -### Automation Example +#### Automation Example -For automation examples, please refer to the available [blueprints](./blueprints.md#wheel-of-fortune). +For automation examples, please refer to the available [blueprints](./blueprints.md#automatically-redeem-octoplus-points-for-account-credit). -## octopus_energy.redeem_octoplus_points_into_account_credit +## Wheel of fortune -Allows you to redeem a certain number of of Octoplus points and convert them into account credit. +### octopus_energy.spin_wheel_of_fortune -!!! info - This service is only available if you have signed up to Octoplus +This service allows the user to perform a spin on the [wheel of fortune](./entities/wheel_of_fortune.md) that is awarded to users every month. No point letting them go to waste :) + +!!! warning + + Due to an ongoing issue with the underlying API, this will not award octopoints if used. If you are on Octoplus, it is advised not to use this service. | Attribute | Optional | Description | | ------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------- | -| `target.entity_id` | `no` | The name of the Octoplus points that hold the points to be redeemed. This should always point at one of the [octoplus points sensor](./entities/octoplus.md#octoplus-points) entities. | -| `data.points_to_redeem` | `no` | The number of points to redeem. | +| `target.entity_id` | `no` | The name of the wheel of fortune sensor that represents the type of spin to be made. This should always point at one of the [wheel of fortune sensors](./entities/wheel_of_fortune.md) entities. | -### Automation Example +#### Automation Example -For automation examples, please refer to the available [blueprints](./blueprints.md#automatically-redeem-octoplus-points-for-account-credit). +For automation examples, please refer to the available [blueprints](./blueprints.md#wheel-of-fortune). + +## Cost Trackers -## octopus_energy.update_cost_tracker +### octopus_energy.update_cost_tracker This service allows the user to turn the tracking on/off for a given [cost tracker](./setup/cost_tracker.md) sensor. @@ -198,11 +195,11 @@ This service allows the user to turn the tracking on/off for a given [cost track | `target.entity_id` | `no` | The name of the cost tracker sensor(s) whose configuration is to be updated. | | `data.is_tracking_enabled` | `no` | Determines if tracking should be enabled (true) or disabled (false) for the specified cost trackers | -### Automation Example +#### Automation Example For automation examples, please refer to the available [blueprints](./blueprints.md#cost-tracker). -## octopus_energy.reset_cost_tracker +### octopus_energy.reset_cost_tracker Resets a given [cost tracker](./setup/cost_tracker.md) sensor back to zero before it's normal reset time. @@ -210,7 +207,7 @@ Resets a given [cost tracker](./setup/cost_tracker.md) sensor back to zero befor | ------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------- | | `target.entity_id` | `no` | The name of the cost tracker sensor(s) that should be reset. | -## octopus_energy.adjust_accumulative_cost_tracker +### octopus_energy.adjust_accumulative_cost_tracker Allows you to adjust the cost/consumption for any given date recorded by an accumulative [cost tracker](./setup/cost_tracker.md) sensor (e.g. week or month). @@ -221,7 +218,7 @@ Allows you to adjust the cost/consumption for any given date recorded by an accu | `data.consumption` | `no` | The new consumption recorded against the specified date. | | `data.cost` | `no` | The new cost recorded against the specified date. | -## octopus_energy.adjust_cost_tracker +### octopus_energy.adjust_cost_tracker Allows you to adjust the consumption for any given period recorded by a [cost tracker](./setup/cost_tracker.md) sensor representing today. @@ -231,39 +228,9 @@ Allows you to adjust the consumption for any given period recorded by a [cost tr | `data.date` | `no` | The date of the data within the cost tracker to be adjusted. | | `data.consumption` | `no` | The new consumption recorded against the specified date. | -## octopus_energy.register_rate_weightings - -Allows you to configure weightings against rates at given times using factors external to the integration. These are applied when calculating [target rates](./setup/target_rate.md#external-rate-weightings) or [rolling target rates](./setup/rolling_target_rate.md#external-rate-weightings). - -Rate weightings are added to any existing rate weightings that have been previously configured. Any rate weightings that are more than 24 hours old are removed. Any rate weightings for periods that have been previously configured are overridden. - -| Attribute | Optional | Description | -| ------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------- | -| `target.entity_id` | `no` | The name of the electricity current rate sensor for the rates the weighting should be applied to (e.g. `sensor.octopus_energy_electricity_{{METER_SERIAL_NUMBER}}_{{MPAN_NUMBER}}_current_rate`). | -| `data.weightings` | `no` | The collection of weightings to add. Each item in the array should represent a given 30 minute period. Example array is `[{ "start": "2025-01-01T00:00:00Z", "end": "2025-01-01T00:30:00Z", "weighting": 0.1 }]` | - -### Automation Example - -This automation adds weightings based on the national grids carbon intensity, as provided by [Carbon Intensity](https://github.com/BottlecapDave/HomeAssistant-CarbonIntensity). - -```yaml -- alias: Carbon Intensity Rate Weightings - triggers: - - platform: state - entity_id: event.carbon_intensity_national_current_day_rates - actions: - - action: octopus_energy.register_rate_weightings - target: - entity_id: sensor.octopus_energy_electricity_xxx_xxx_current_rate - data: - weightings: > - {% set forecast = state_attr('event.carbon_intensity_national_current_day_rates', 'rates') + state_attr('event.carbon_intensity_national_next_day_rates', 'rates') %} - {% set ns = namespace(list = []) %} {%- for a in forecast -%} - {%- set ns.list = ns.list + [{ "start": a.from.strftime('%Y-%m-%dT%H:%M:%SZ'), "end": a.to.strftime('%Y-%m-%dT%H:%M:%SZ'), "weighting": a.intensity_forecast | float }] -%} - {%- endfor -%} {{ ns.list }} -``` +## Heat Pump -## octopus_energy.boost_heat_pump_zone +### octopus_energy.boost_heat_pump_zone Allows you to boost a given heat pump zone for a set amount of time. @@ -278,7 +245,7 @@ Allows you to boost a given heat pump zone for a set amount of time. If you boost and a target temperature is both not provided and not defined on the sensor itself, then a default value will be set. This will be 50 degrees C for `water` zones and 30 degrees C for all other zones. -## octopus_energy.set_heat_pump_flow_temp_config +### octopus_energy.set_heat_pump_flow_temp_config Allows you to set the heat pump configuration for fixed and weather compensated flow temperatures, with the option to select which is active. @@ -292,3 +259,68 @@ Allows you to set the heat pump configuration for fixed and weather compensated | `data.weather_comp_min_temperature` | `no` | Minimum allowable temperature for weather compensation, typically no lower than 30. | | `data.weather_comp_max_temperature` | `no` | Maximum allowable temperature for weather compensation, typically no higher than 70. | | `data.fixed_flow_temperature` | `no` | If a fixed flow temperature is enabled this value will be used, typically between 30 and 70. | + +## Intelligent + +### octopus_energy.refresh_intelligent_dispatches + +Refreshes intelligent dispatches for a given account. + +!!! info + + This service is only available if you have switched to [manual polling](./setup/account.md#manually-refresh-intelligent-dispatches) in your configuration. + +!!! warning + + This service can only be called a maximum of 20 times per hour. + +| Attribute | Optional | Description | +| ------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------- | +| `target.entity_id` | `no` | The [dispatching](./entities/intelligent.md#is-dispatching) entity that you want to refresh the content for (e.g. `binary_sensor.octopus_energy_{{ACCOUNT_ID}}_intelligent_dispatching`). | + +## Miscellaneous + +### octopus_energy.purge_invalid_external_statistic_ids + +For removing all external statistics that are associated with meters that don't have an active tariff. This is useful if you've been using the integration and obtained new smart meters. + +### octopus_energy.refresh_previous_consumption_data + +For refreshing the consumption/cost information for a given previous consumption entity. This is useful when you've just installed the integration and want old data brought in or a previous consumption sensor fails to import (e.g. data becomes available outside of the configured offset). The service will raise a notification when the refreshing starts and finishes. + +This service is only available for the following sensors + +- `sensor.octopus_energy_electricity_{{METER_SERIAL_NUMBER}}_{{MPAN_NUMBER}}_previous_accumulative_consumption` (this will populate both consumption and cost) +- `sensor.octopus_energy_gas_{{METER_SERIAL_NUMBER}}_{{MPRN_NUMBER}}_previous_accumulative_consumption_m3` (this will populate both consumption and cost for both m3 and kwh) + +### octopus_energy.register_rate_weightings + +Allows you to configure weightings against rates at given times using factors external to the integration. These are applied when calculating [target rates](./setup/target_rate.md#external-rate-weightings) or [rolling target rates](./setup/rolling_target_rate.md#external-rate-weightings). + +Rate weightings are added to any existing rate weightings that have been previously configured. Any rate weightings that are more than 24 hours old are removed. Any rate weightings for periods that have been previously configured are overridden. + +| Attribute | Optional | Description | +| ------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------- | +| `target.entity_id` | `no` | The name of the electricity current rate sensor for the rates the weighting should be applied to (e.g. `sensor.octopus_energy_electricity_{{METER_SERIAL_NUMBER}}_{{MPAN_NUMBER}}_current_rate`). | +| `data.weightings` | `no` | The collection of weightings to add. Each item in the array should represent a given 30 minute period. Example array is `[{ "start": "2025-01-01T00:00:00Z", "end": "2025-01-01T00:30:00Z", "weighting": 0.1 }]` | + +#### Automation Example + +This automation adds weightings based on the national grids carbon intensity, as provided by [Carbon Intensity](https://github.com/BottlecapDave/HomeAssistant-CarbonIntensity). + +```yaml +- alias: Carbon Intensity Rate Weightings + triggers: + - platform: state + entity_id: event.carbon_intensity_national_current_day_rates + actions: + - action: octopus_energy.register_rate_weightings + target: + entity_id: sensor.octopus_energy_electricity_xxx_xxx_current_rate + data: + weightings: > + {% set forecast = state_attr('event.carbon_intensity_national_current_day_rates', 'rates') + state_attr('event.carbon_intensity_national_next_day_rates', 'rates') %} + {% set ns = namespace(list = []) %} {%- for a in forecast -%} + {%- set ns.list = ns.list + [{ "start": a.from.strftime('%Y-%m-%dT%H:%M:%SZ'), "end": a.to.strftime('%Y-%m-%dT%H:%M:%SZ'), "weighting": a.intensity_forecast | float }] -%} + {%- endfor -%} {{ ns.list }} +``` \ No newline at end of file diff --git a/_docs/setup/account.md b/_docs/setup/account.md index cfd3707b..979dcd02 100644 --- a/_docs/setup/account.md +++ b/_docs/setup/account.md @@ -47,6 +47,10 @@ There are some tariffs where direct debit and non direct debit rates are availab It might take a couple of minutes for these changes to reflect once changed. +## Manually refresh intelligent dispatches + +By default, intelligent dispatches are retrieved [periodically](../faq.md#how-often-is-data-refreshed). This is fine for most scenarios, but this can be a little slow depending on what else you're doing off the back of the dispatches. If you have other ways of knowing when new dispatches should be available (e.g. your charger changes to a charging state or a manual button in your HA dashboard), then you can turn on `Manually refresh intelligent dispatches`. This will disable the periodic refreshing and expose a [service](../services.md#octopus_energyrefresh_intelligent_dispatches) which can be called to refresh the dispatches. + ## Home Pro If you are lucky enough to own an [Octopus Home Pro](https://forum.octopus.energy/t/for-the-pro-user/8453/2352/), you can now receive this data locally from within Home Assistant. diff --git a/_docs/setup/cost_tracker.md b/_docs/setup/cost_tracker.md index 6d22ee1e..0706a6bd 100644 --- a/_docs/setup/cost_tracker.md +++ b/_docs/setup/cost_tracker.md @@ -292,4 +292,4 @@ This is the total cost of the tracked entity for the current month during peak h ## Services -There are services available associated with cost tracker sensors. Please review them in the [services doc](../services.md#octopus_energyupdate_cost_tracker). \ No newline at end of file +There are services available associated with cost tracker sensors. Please review them in the [services doc](../services.md#cost-trackers). \ No newline at end of file diff --git a/_docs/setup/rolling_target_rate.md b/_docs/setup/rolling_target_rate.md index f38ccf32..7066bc7c 100644 --- a/_docs/setup/rolling_target_rate.md +++ b/_docs/setup/rolling_target_rate.md @@ -175,4 +175,4 @@ The following attributes are available on each sensor ## Services -There are services available associated with target rate sensors. Please review them in the [services doc](../services.md). +There are services available associated with target rate sensors. Please review them in the [services doc](../services.md#rolling-target-rates). diff --git a/_docs/setup/target_rate.md b/_docs/setup/target_rate.md index 10498cfe..5ab380fe 100644 --- a/_docs/setup/target_rate.md +++ b/_docs/setup/target_rate.md @@ -221,7 +221,7 @@ The following attributes are available on each sensor ## Services -There are services available associated with target rate sensors. Please review them in the [services doc](../services.md). +There are services available associated with target rate sensors. Please review them in the [services doc](../services.md#target-rates). ## Examples diff --git a/custom_components/octopus_energy/__init__.py b/custom_components/octopus_energy/__init__.py index c764f800..df93a99f 100644 --- a/custom_components/octopus_energy/__init__.py +++ b/custom_components/octopus_energy/__init__.py @@ -39,7 +39,7 @@ from .storage.heat_pump import async_load_cached_heat_pump, async_save_cached_heat_pump from .const import ( - CONFIG_FAVOUR_DIRECT_DEBIT_RATES, + CONFIG_MAIN_FAVOUR_DIRECT_DEBIT_RATES, CONFIG_KIND, CONFIG_KIND_ACCOUNT, CONFIG_KIND_ROLLING_TARGET_RATE, @@ -48,6 +48,7 @@ CONFIG_KIND_TARGET_RATE, CONFIG_MAIN_HOME_PRO_ADDRESS, CONFIG_MAIN_HOME_PRO_API_KEY, + CONFIG_MAIN_INTELLIGENT_MANUAL_DISPATCHES, CONFIG_MAIN_OLD_API_KEY, CONFIG_VERSION, DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_KEY, @@ -67,6 +68,7 @@ DATA_CLIENT, DATA_ELECTRICITY_RATES_COORDINATOR_KEY, DATA_ACCOUNT, + REFRESH_RATE_IN_MINUTES_INTELLIGENT, REPAIR_ACCOUNT_NOT_FOUND, REPAIR_INVALID_API_KEY, REPAIR_UNIQUE_RATES_CHANGED_KEY, @@ -279,8 +281,8 @@ async def async_setup_dependencies(hass, config): gas_price_cap = config[CONFIG_MAIN_GAS_PRICE_CAP] favour_direct_debit_rates = True - if CONFIG_FAVOUR_DIRECT_DEBIT_RATES in config: - favour_direct_debit_rates = config[CONFIG_FAVOUR_DIRECT_DEBIT_RATES] + if CONFIG_MAIN_FAVOUR_DIRECT_DEBIT_RATES in config: + favour_direct_debit_rates = config[CONFIG_MAIN_FAVOUR_DIRECT_DEBIT_RATES] _LOGGER.info(f'electricity_price_cap: {electricity_price_cap}') _LOGGER.info(f'gas_price_cap: {gas_price_cap}') @@ -389,6 +391,7 @@ async def async_setup_dependencies(hass, config): intelligent_serial_number = meter["serial_number"] break + intelligent_manual_service = False intelligent_device = None if has_intelligent_tariff or should_mock_intelligent_data: client: OctopusEnergyApiClient = hass.data[DOMAIN][account_id][DATA_CLIENT] @@ -415,6 +418,9 @@ async def async_setup_dependencies(hass, config): hass.data[DOMAIN][account_id][DATA_INTELLIGENT_MPAN] = intelligent_mpan hass.data[DOMAIN][account_id][DATA_INTELLIGENT_SERIAL_NUMBER] = intelligent_serial_number + if CONFIG_MAIN_INTELLIGENT_MANUAL_DISPATCHES not in config or config[CONFIG_MAIN_INTELLIGENT_MANUAL_DISPATCHES] == False: + intelligent_manual_service = True + await async_save_cached_intelligent_device(hass, account_id, intelligent_device) intelligent_features = get_intelligent_features(intelligent_device.provider) if intelligent_device is not None else None @@ -430,6 +436,21 @@ async def async_setup_dependencies(hass, config): translation_placeholders={ "account_id": account_id, "provider": intelligent_device.provider }, ) + intelligent_repair_key = f"intelligent_manual_service_{account_id}" + if intelligent_manual_service and intelligent_features is not None and intelligent_features.planned_dispatches_supported: + ir.async_create_issue( + hass, + DOMAIN, + intelligent_repair_key, + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + learn_more_url="https://bottlecapdave.github.io/HomeAssistant-OctopusEnergy/services/#octopus_energyrefresh_intelligent_dispatches", + translation_key="intelligent_manual_service", + translation_placeholders={ "account_id": account_id, "polling_time": REFRESH_RATE_IN_MINUTES_INTELLIGENT }, + ) + else: + ir.async_delete_issue(hass, DOMAIN, intelligent_repair_key) + for point in account_info["electricity_meter_points"]: # We only care about points that have active agreements electricity_tariff = get_active_tariff(now, point["agreements"]) @@ -468,7 +489,12 @@ async def async_setup_dependencies(hass, config): await async_setup_account_info_coordinator(hass, account_id) - 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_dispatches_coordinator( + hass, + account_id, + account_debug_override.mock_intelligent_controls if account_debug_override is not None else False, + config[CONFIG_MAIN_INTELLIGENT_MANUAL_DISPATCHES] == True if CONFIG_MAIN_INTELLIGENT_MANUAL_DISPATCHES in config 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) diff --git a/custom_components/octopus_energy/api_client/__init__.py b/custom_components/octopus_energy/api_client/__init__.py index 0509dfd5..ab579adb 100644 --- a/custom_components/octopus_energy/api_client/__init__.py +++ b/custom_components/octopus_energy/api_client/__init__.py @@ -1873,6 +1873,8 @@ async def __async_read_response__(self, response, url, ignore_errors = False): _LOGGER.info(f"Response received - {url} ({request_context}) - Unexpected response received: {response.status}; {text}") return None + + _LOGGER.debug(f'Response received - {url} ({request_context}) - Successful response') data_as_json = None try: diff --git a/custom_components/octopus_energy/binary_sensor.py b/custom_components/octopus_energy/binary_sensor.py index c70b6a35..a19b6b26 100644 --- a/custom_components/octopus_energy/binary_sensor.py +++ b/custom_components/octopus_energy/binary_sensor.py @@ -22,6 +22,7 @@ CONFIG_KIND_ROLLING_TARGET_RATE, CONFIG_KIND_TARGET_RATE, CONFIG_ACCOUNT_ID, + CONFIG_MAIN_INTELLIGENT_MANUAL_DISPATCHES, DATA_FREE_ELECTRICITY_SESSIONS_COORDINATOR, DATA_GREENNESS_FORECAST_COORDINATOR, DATA_INTELLIGENT_DEVICE, @@ -139,6 +140,20 @@ async def async_setup_main_sensors(hass, entry, async_add_entities): intelligent_mpan = hass.data[DOMAIN][account_id][DATA_INTELLIGENT_MPAN] if DATA_INTELLIGENT_MPAN in hass.data[DOMAIN][account_id] else None intelligent_serial_number = hass.data[DOMAIN][account_id][DATA_INTELLIGENT_SERIAL_NUMBER] if DATA_INTELLIGENT_SERIAL_NUMBER in hass.data[DOMAIN][account_id] else None if intelligent_device is not None and intelligent_mpan is not None and intelligent_serial_number is not None: + + if CONFIG_MAIN_INTELLIGENT_MANUAL_DISPATCHES in config and config[CONFIG_MAIN_INTELLIGENT_MANUAL_DISPATCHES] == True: + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + "refresh_intelligent_dispatches", + vol.All( + cv.make_entity_service_schema( + {}, + extra=vol.ALLOW_EXTRA, + ), + ), + "async_refresh_dispatches" + ) + intelligent_features = get_intelligent_features(intelligent_device.provider) coordinator = hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DISPATCHES_COORDINATOR] electricity_rate_coordinator = hass.data[DOMAIN][account_id][DATA_ELECTRICITY_RATES_COORDINATOR_KEY.format(intelligent_mpan, intelligent_serial_number)] diff --git a/custom_components/octopus_energy/config_flow.py b/custom_components/octopus_energy/config_flow.py index fbec139f..a988ad29 100644 --- a/custom_components/octopus_energy/config_flow.py +++ b/custom_components/octopus_energy/config_flow.py @@ -17,10 +17,11 @@ from .config.main import async_validate_main_config, merge_main_config from .const import ( CONFIG_COST_TRACKER_MANUAL_RESET, - CONFIG_FAVOUR_DIRECT_DEBIT_RATES, + CONFIG_MAIN_FAVOUR_DIRECT_DEBIT_RATES, CONFIG_KIND_ROLLING_TARGET_RATE, CONFIG_MAIN_HOME_PRO_ADDRESS, CONFIG_MAIN_HOME_PRO_API_KEY, + CONFIG_MAIN_INTELLIGENT_MANUAL_DISPATCHES, CONFIG_ROLLING_TARGET_HOURS_LOOK_AHEAD, CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE, CONFIG_TARGET_TARGET_TIMES_EVALUATION_MODE_ALL_IN_FUTURE_OR_PAST, @@ -798,7 +799,8 @@ async def __async_setup_main_schema__(self, config, errors): vol.Required(CONFIG_MAIN_LIVE_GAS_CONSUMPTION_REFRESH_IN_MINUTES): cv.positive_int, vol.Optional(CONFIG_MAIN_ELECTRICITY_PRICE_CAP): cv.positive_float, vol.Optional(CONFIG_MAIN_GAS_PRICE_CAP): cv.positive_float, - vol.Required(CONFIG_FAVOUR_DIRECT_DEBIT_RATES): bool, + vol.Required(CONFIG_MAIN_FAVOUR_DIRECT_DEBIT_RATES): bool, + vol.Required(CONFIG_MAIN_INTELLIGENT_MANUAL_DISPATCHES): bool, }), { CONFIG_MAIN_API_KEY: config[CONFIG_MAIN_API_KEY], @@ -810,7 +812,8 @@ async def __async_setup_main_schema__(self, config, errors): CONFIG_MAIN_LIVE_GAS_CONSUMPTION_REFRESH_IN_MINUTES: live_gas_consumption_refresh_in_minutes, CONFIG_MAIN_ELECTRICITY_PRICE_CAP: config[CONFIG_MAIN_ELECTRICITY_PRICE_CAP] if CONFIG_MAIN_ELECTRICITY_PRICE_CAP in config else None, CONFIG_MAIN_GAS_PRICE_CAP: config[CONFIG_MAIN_GAS_PRICE_CAP] if CONFIG_MAIN_GAS_PRICE_CAP in config else None, - CONFIG_FAVOUR_DIRECT_DEBIT_RATES: config[CONFIG_FAVOUR_DIRECT_DEBIT_RATES] if CONFIG_FAVOUR_DIRECT_DEBIT_RATES in config else True + CONFIG_MAIN_FAVOUR_DIRECT_DEBIT_RATES: config[CONFIG_MAIN_FAVOUR_DIRECT_DEBIT_RATES] if CONFIG_MAIN_FAVOUR_DIRECT_DEBIT_RATES in config else True, + CONFIG_MAIN_INTELLIGENT_MANUAL_DISPATCHES: config[CONFIG_MAIN_INTELLIGENT_MANUAL_DISPATCHES] if CONFIG_MAIN_INTELLIGENT_MANUAL_DISPATCHES in config else False, } ), errors=errors diff --git a/custom_components/octopus_energy/const.py b/custom_components/octopus_energy/const.py index 6d1748a2..537a1440 100644 --- a/custom_components/octopus_energy/const.py +++ b/custom_components/octopus_energy/const.py @@ -40,7 +40,8 @@ CONFIG_MAIN_GAS_PRICE_CAP = "gas_price_cap" CONFIG_MAIN_HOME_PRO_ADDRESS = "home_pro_address" CONFIG_MAIN_HOME_PRO_API_KEY = "home_pro_api_key" -CONFIG_FAVOUR_DIRECT_DEBIT_RATES = "favour_direct_debit_rates" +CONFIG_MAIN_FAVOUR_DIRECT_DEBIT_RATES = "favour_direct_debit_rates" +CONFIG_MAIN_INTELLIGENT_MANUAL_DISPATCHES = "intelligent_manual_dispatches" CONFIG_DEFAULT_LIVE_ELECTRICITY_CONSUMPTION_REFRESH_IN_MINUTES = 1 CONFIG_DEFAULT_LIVE_GAS_CONSUMPTION_REFRESH_IN_MINUTES = 2 @@ -188,7 +189,8 @@ vol.Required(CONFIG_MAIN_LIVE_GAS_CONSUMPTION_REFRESH_IN_MINUTES, default=CONFIG_DEFAULT_LIVE_ELECTRICITY_CONSUMPTION_REFRESH_IN_MINUTES): cv.positive_int, vol.Optional(CONFIG_MAIN_ELECTRICITY_PRICE_CAP): cv.positive_float, vol.Optional(CONFIG_MAIN_GAS_PRICE_CAP): cv.positive_float, - vol.Required(CONFIG_FAVOUR_DIRECT_DEBIT_RATES): bool + vol.Required(CONFIG_MAIN_FAVOUR_DIRECT_DEBIT_RATES): bool, + vol.Required(CONFIG_MAIN_INTELLIGENT_MANUAL_DISPATCHES): bool, }) EVENT_ELECTRICITY_PREVIOUS_DAY_RATES = "octopus_energy_electricity_previous_day_rates" diff --git a/custom_components/octopus_energy/coordinators/intelligent_dispatches.py b/custom_components/octopus_energy/coordinators/intelligent_dispatches.py index d94509fa..1ea42631 100644 --- a/custom_components/octopus_energy/coordinators/intelligent_dispatches.py +++ b/custom_components/octopus_energy/coordinators/intelligent_dispatches.py @@ -32,12 +32,51 @@ _LOGGER = logging.getLogger(__name__) +MAXIMUM_RATES_PER_HOUR = 20 + +class IntelligentDispatchDataUpdateCoordinator(DataUpdateCoordinator): + + def __init__(self, hass, name: str, account_id: str, manual_dispatch_refreshes: bool, refresh_dispatches) -> None: + """Initialize coordinator.""" + self.__refresh_dispatches = refresh_dispatches + self.__manual_dispatch_refreshes = manual_dispatch_refreshes + self.__account_id = account_id + super().__init__( + hass, + _LOGGER, + name=name, + update_method=self.__automatic_refresh_dispatches, + update_interval=timedelta(seconds=COORDINATOR_REFRESH_IN_SECONDS), + always_update=True + ) + + async def __automatic_refresh_dispatches(self): + if self.__manual_dispatch_refreshes == False: + return await self.__refresh_dispatches() + + return ( + self.hass.data[DOMAIN][self.__account_id][DATA_INTELLIGENT_DISPATCHES] + if DOMAIN in self.hass.data and self.__account_id in self.hass.data[DOMAIN] and DATA_INTELLIGENT_DISPATCHES in self.hass.data[DOMAIN][self.__account_id] + else None + ) + + async def refresh_dispatches(self): + _LOGGER.debug('Refreshing dispatches') + result = await self.__refresh_dispatches(is_manual_refresh=True) + self.data = result + self.async_update_listeners() + return result + class IntelligentDispatchesCoordinatorResult(BaseCoordinatorResult): dispatches: IntelligentDispatches + requests_current_hour: int + requests_current_hour_last_reset: datetime - def __init__(self, last_evaluated: datetime, request_attempts: int, dispatches: IntelligentDispatches, last_error: Exception | None = None): + def __init__(self, last_evaluated: datetime, request_attempts: int, dispatches: IntelligentDispatches, requests_current_hour: int, requests_current_hour_last_reset: datetime, last_error: Exception | None = None): super().__init__(last_evaluated, request_attempts, REFRESH_RATE_IN_MINUTES_INTELLIGENT, None, last_error) self.dispatches = dispatches + self.requests_current_hour = requests_current_hour + self.requests_current_hour_last_reset = requests_current_hour_last_reset async def async_merge_dispatch_data(hass, account_id: str, completed_dispatches): storage_key = STORAGE_COMPLETED_DISPATCHES_NAME.format(account_id) @@ -63,11 +102,32 @@ async def async_refresh_intelligent_dispatches( intelligent_device: IntelligentDevice, existing_intelligent_dispatches_result: IntelligentDispatchesCoordinatorResult, is_data_mocked: bool, + is_manual_refresh: bool, async_merge_dispatch_data: Callable[[str, list], Awaitable[list]] ): + requests_current_hour = existing_intelligent_dispatches_result.requests_current_hour if existing_intelligent_dispatches_result is not None else 0 + requests_last_reset = existing_intelligent_dispatches_result.requests_current_hour_last_reset if existing_intelligent_dispatches_result is not None else current + + if current - requests_last_reset >= timedelta(hours=1): + requests_current_hour = 0 + requests_last_reset = current + + if requests_current_hour >= MAXIMUM_RATES_PER_HOUR: + _LOGGER.debug('Maximum requests reached for current hour') + return IntelligentDispatchesCoordinatorResult( + existing_intelligent_dispatches_result.last_evaluated, + existing_intelligent_dispatches_result.request_attempts, + existing_intelligent_dispatches_result.dispatches, + existing_intelligent_dispatches_result.requests_current_hour, + existing_intelligent_dispatches_result.requests_current_hour_last_reset, + last_error=f"Maximum requests of {MAXIMUM_RATES_PER_HOUR}/hour reached. Will reset after {requests_last_reset + timedelta(hours=1)}" + ) + if (account_info is not None): account_id = account_info["id"] - if (existing_intelligent_dispatches_result is None or current >= existing_intelligent_dispatches_result.next_refresh): + if (existing_intelligent_dispatches_result is None or + current >= existing_intelligent_dispatches_result.next_refresh or + is_manual_refresh): dispatches = None raised_exception = None if has_intelligent_tariff(current, account_info) and intelligent_device is not None: @@ -83,10 +143,11 @@ async def async_refresh_intelligent_dispatches( if is_data_mocked: dispatches = mock_intelligent_dispatches() + _LOGGER.debug(f'Intelligent dispatches mocked for account {account_id}') if dispatches is not None: dispatches.completed = await async_merge_dispatch_data(account_id, dispatches.completed) - return IntelligentDispatchesCoordinatorResult(current, 1, dispatches) + return IntelligentDispatchesCoordinatorResult(current, 1, dispatches, requests_current_hour + 1, requests_last_reset) result = None if (existing_intelligent_dispatches_result is not None): @@ -94,6 +155,8 @@ async def async_refresh_intelligent_dispatches( existing_intelligent_dispatches_result.last_evaluated, existing_intelligent_dispatches_result.request_attempts + 1, existing_intelligent_dispatches_result.dispatches, + existing_intelligent_dispatches_result.requests_current_hour + 1, + existing_intelligent_dispatches_result.requests_current_hour_last_reset, last_error=raised_exception ) @@ -101,18 +164,18 @@ async def async_refresh_intelligent_dispatches( _LOGGER.warning(f"Failed to retrieve new dispatches - using cached dispatches. See diagnostics sensor for more information.") else: # We want to force into our fallback mode - result = IntelligentDispatchesCoordinatorResult(current - timedelta(minutes=REFRESH_RATE_IN_MINUTES_INTELLIGENT), 2, None, last_error=raised_exception) + result = IntelligentDispatchesCoordinatorResult(current - timedelta(minutes=REFRESH_RATE_IN_MINUTES_INTELLIGENT), 2, None, requests_current_hour, requests_last_reset, last_error=raised_exception) _LOGGER.warning(f"Failed to retrieve new dispatches. See diagnostics sensor for more information.") return result return existing_intelligent_dispatches_result -async def async_setup_intelligent_dispatches_coordinator(hass, account_id: str, mock_intelligent_data: bool): +async def async_setup_intelligent_dispatches_coordinator(hass, account_id: str, mock_intelligent_data: bool, manual_dispatch_refreshes: bool): # Reset data rates as we might have new information hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DISPATCHES] = None - async def async_update_intelligent_dispatches_data(): + async def async_update_intelligent_dispatches_data(is_manual_refresh = False): """Fetch data from API endpoint.""" # Request our account data to be refreshed account_coordinator = hass.data[DOMAIN][account_id][DATA_ACCOUNT_COORDINATOR] @@ -131,18 +194,16 @@ async def async_update_intelligent_dispatches_data(): hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DEVICE] if DATA_INTELLIGENT_DEVICE in hass.data[DOMAIN][account_id] else None, hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DISPATCHES] if DATA_INTELLIGENT_DISPATCHES in hass.data[DOMAIN][account_id] else None, mock_intelligent_data, + is_manual_refresh, lambda account_id, completed_dispatches: async_merge_dispatch_data(hass, account_id, completed_dispatches) ) return hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DISPATCHES] - hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DISPATCHES_COORDINATOR] = DataUpdateCoordinator( + hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DISPATCHES_COORDINATOR] = IntelligentDispatchDataUpdateCoordinator( hass, - _LOGGER, name=f"intelligent_dispatches-{account_id}", - update_method=async_update_intelligent_dispatches_data, - # Because of how we're using the data, we'll update every minute, but we will only actually retrieve - # data every 30 minutes - update_interval=timedelta(seconds=COORDINATOR_REFRESH_IN_SECONDS), - always_update=True + account_id=account_id, + refresh_dispatches=async_update_intelligent_dispatches_data, + manual_dispatch_refreshes=manual_dispatch_refreshes ) \ No newline at end of file diff --git a/custom_components/octopus_energy/diagnostics_entities/intelligent_dispatches_data_last_retrieved.py b/custom_components/octopus_energy/diagnostics_entities/intelligent_dispatches_data_last_retrieved.py index 86369a03..ce2632c2 100644 --- a/custom_components/octopus_energy/diagnostics_entities/intelligent_dispatches_data_last_retrieved.py +++ b/custom_components/octopus_energy/diagnostics_entities/intelligent_dispatches_data_last_retrieved.py @@ -1,3 +1,11 @@ +from homeassistant.core import callback + +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity +) + +from ..utils.error import exception_to_string +from ..coordinators.intelligent_dispatches import IntelligentDispatchesCoordinatorResult from .base import OctopusEnergyBaseDataLastRetrieved class OctopusEnergyIntelligentDispatchesDataLastRetrieved(OctopusEnergyBaseDataLastRetrieved): @@ -16,4 +24,19 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Intelligent Dispatches Data Last Retrieved ({self._account_id})" \ No newline at end of file + return f"Intelligent Dispatches Data Last Retrieved ({self._account_id})" + + @callback + def _handle_coordinator_update(self) -> None: + result: IntelligentDispatchesCoordinatorResult = self.coordinator.data if self.coordinator is not None and self.coordinator.data is not None else None + self._state = result.last_retrieved if result is not None else None + + self._attributes = { + "attempts": result.request_attempts if result is not None else None, + "next_refresh": result.next_refresh if result is not None else None, + "last_error": exception_to_string(result.last_error) if result is not None else None, + "requests_current_hour": result.requests_current_hour if result is not None else None, + "request_limits_last_reset": result.requests_current_hour_last_reset if result is not None else None + } + + CoordinatorEntity._handle_coordinator_update(self) \ No newline at end of file diff --git a/custom_components/octopus_energy/icons.json b/custom_components/octopus_energy/icons.json index 2ba261ad..51a8f720 100644 --- a/custom_components/octopus_energy/icons.json +++ b/custom_components/octopus_energy/icons.json @@ -12,6 +12,7 @@ "adjust_cost_tracker": "mdi:numeric", "redeem_octoplus_points_into_account_credit": "mdi:currency-gbp", "boost_heat_pump_zone": "mdi:thermometer-plus", - "set_heat_pump_flow_temp_config": "mdi:heat-pump" + "set_heat_pump_flow_temp_config": "mdi:heat-pump", + "refresh_intelligent_dispatches": "mdi:refresh" } } \ No newline at end of file diff --git a/custom_components/octopus_energy/intelligent/dispatching.py b/custom_components/octopus_energy/intelligent/dispatching.py index 429f211c..44d87195 100644 --- a/custom_components/octopus_energy/intelligent/dispatching.py +++ b/custom_components/octopus_energy/intelligent/dispatching.py @@ -6,11 +6,9 @@ ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import generate_entity_id +from homeassistant.exceptions import ServiceValidationError -from homeassistant.util.dt import (now, utcnow) -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity -) +from homeassistant.util.dt import (utcnow) from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) @@ -22,7 +20,7 @@ from ..utils import get_off_peak_times from .base import OctopusEnergyIntelligentSensor -from ..coordinators.intelligent_dispatches import IntelligentDispatchesCoordinatorResult +from ..coordinators.intelligent_dispatches import IntelligentDispatchDataUpdateCoordinator, IntelligentDispatchesCoordinatorResult from ..utils.attributes import dict_to_typed_dict from ..api_client.intelligent_device import IntelligentDevice from ..coordinators import MultiCoordinatorEntity @@ -32,7 +30,7 @@ class OctopusEnergyIntelligentDispatching(MultiCoordinatorEntity, BinarySensorEntity, OctopusEnergyIntelligentSensor, RestoreEntity): """Sensor for determining if an intelligent is dispatching.""" - def __init__(self, hass: HomeAssistant, coordinator, rates_coordinator, mpan: str, device: IntelligentDevice, account_id: str, planned_dispatches_supported: bool): + def __init__(self, hass: HomeAssistant, coordinator: IntelligentDispatchDataUpdateCoordinator, rates_coordinator, mpan: str, device: IntelligentDevice, account_id: str, planned_dispatches_supported: bool): """Init sensor.""" MultiCoordinatorEntity.__init__(self, coordinator, [rates_coordinator]) @@ -145,3 +143,10 @@ async def async_added_to_hass(self): self._state = False _LOGGER.debug(f'Restored OctopusEnergyIntelligentDispatching state: {self._state}') + + @callback + async def async_refresh_dispatches(self): + """Refresh dispatches""" + result: IntelligentDispatchesCoordinatorResult = await self.coordinator.refresh_dispatches() + if result is not None and result.last_error is not None: + raise ServiceValidationError(result.last_error) diff --git a/custom_components/octopus_energy/services.yaml b/custom_components/octopus_energy/services.yaml index 726111bb..f6292559 100644 --- a/custom_components/octopus_energy/services.yaml +++ b/custom_components/octopus_energy/services.yaml @@ -344,4 +344,12 @@ set_heat_pump_flow_temp_config: step: 0.5 min: 30 max: 70 - mode: box \ No newline at end of file + mode: box + +refresh_intelligent_dispatches: + name: Refresh intelligent dispatches + description: Refreshes the intelligent dispatches. For more information please see https://bottlecapdave.github.io/HomeAssistant-OctopusEnergy/services/#octopus_energyrefresh_intelligent_dispatches. + target: + entity: + integration: octopus_energy + domain: binary_sensor \ No newline at end of file diff --git a/custom_components/octopus_energy/translations/en.json b/custom_components/octopus_energy/translations/en.json index 5234ec78..8eab5b09 100644 --- a/custom_components/octopus_energy/translations/en.json +++ b/custom_components/octopus_energy/translations/en.json @@ -15,7 +15,8 @@ "gas_price_cap": "Optional gas price cap in pence", "home_pro_address": "Home Pro address (e.g. http://192.168.0.1)", "home_pro_api_key": "Home Pro API key", - "favour_direct_debit_rates": "Favour direct debit rates where available" + "favour_direct_debit_rates": "Favour direct debit rates where available", + "intelligent_manual_dispatches": "Manually refresh intelligent dispatches" }, "data_description": { "account_id": "You account ID can be found on your bill or at the top of https://octopus.energy/dashboard", @@ -24,7 +25,8 @@ "electricity_price_cap": "This usually comes from the OE APIs and doesn't need to be set", "gas_price_cap": "This usually comes from the OE APIs and doesn't need to be set", "home_pro_address": "WARNING: This is experimental.", - "home_pro_api_key": "WARNING: This is experimental. This is only required if you have setup the custom API." + "home_pro_api_key": "WARNING: This is experimental. This is only required if you have setup the custom API.", + "intelligent_manual_dispatches": "This will expose a service which can be called to refresh the dispatches on demand. See docs for more information." } }, "target_rate": { @@ -167,7 +169,8 @@ "gas_price_cap": "Optional gas price cap in pence", "home_pro_address": "Home Pro address (e.g. http://192.168.0.1)", "home_pro_api_key": "Home Pro API key", - "favour_direct_debit_rates": "Favour direct debit rates where available" + "favour_direct_debit_rates": "Favour direct debit rates where available", + "intelligent_manual_dispatches": "Manually refresh intelligent dispatches" }, "data_description": { "api_key": "You API key can be found at https://octopus.energy/dashboard/new/accounts/personal-details/api-access", @@ -175,7 +178,8 @@ "electricity_price_cap": "This usually comes from the OE APIs and doesn't need to be set", "gas_price_cap": "This usually comes from the OE APIs and doesn't need to be set", "home_pro_address": "WARNING: This is experimental", - "home_pro_api_key": "WARNING: This is experimental. This is only required if you have setup the custom API." + "home_pro_api_key": "WARNING: This is experimental. This is only required if you have setup the custom API.", + "intelligent_manual_dispatches": "This will expose a service which can be called to refresh the dispatches on demand. See docs for more information." } }, "target_rate": { @@ -333,6 +337,10 @@ "intelligent_target_time_deprecated": { "title": "Intelligent target time sensor has been deprecated", "description": "The target time sensor (defaults to time.octopus_energy_{account_id}_intelligent_target_time) has been deprecated in favour of a select based sensor (select.octopus_energy_{account_id}_intelligent_target_time) to make it easier to select a valid time. This old sensor will be removed in a future release." + }, + "intelligent_manual_service": { + "title": "Manual intelligent dispatch refresh service available for {account_id}", + "description": "By default, dispatches are retrieved every {polling_time} minutes. If you know when your car has started charging via other means, you might want to activate manual polling mode which will expose a service for retrieving dispatches on-demand. This is the recommended way to get the information as soon as possible and reduce unnecessary calls to the Octopus Energy servers. If you can't do this, then just ignore this repair notice and continue with the automatic polling.\n\nClick \"Learn More\" to find out more about the service." } } } \ No newline at end of file diff --git a/tests/unit/coordinators/test_async_refresh_electricity_rates_data.py b/tests/unit/coordinators/test_async_refresh_electricity_rates_data.py index 110132d1..f4757f02 100644 --- a/tests/unit/coordinators/test_async_refresh_electricity_rates_data.py +++ b/tests/unit/coordinators/test_async_refresh_electricity_rates_data.py @@ -103,7 +103,7 @@ def fire_event(name, metadata): account_info = None existing_rates = ElectricityRatesCoordinatorResult(period_from, 1, create_rate_data(period_from, period_to, [2, 4])) - dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], [])) + dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], []), 1, dispatches_last_retrieved) with mock.patch.multiple(OctopusEnergyApiClient, async_get_electricity_rates=async_mocked_get_electricity_rates): client = OctopusEnergyApiClient("NOT_REAL") @@ -143,7 +143,7 @@ def fire_event(name, metadata): account_info = get_account_info(False) existing_rates = ElectricityRatesCoordinatorResult(period_from, 1, create_rate_data(period_from, period_to, [2, 4])) - dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], [])) + dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], []), 1, dispatches_last_retrieved) with mock.patch.multiple(OctopusEnergyApiClient, async_get_electricity_rates=async_mocked_get_electricity_rates): client = OctopusEnergyApiClient("NOT_REAL") @@ -184,7 +184,7 @@ def fire_event(name, metadata): account_info = get_account_info() existing_rates = ElectricityRatesCoordinatorResult(current - timedelta(minutes=4, seconds=59), 1, create_rate_data(period_from, period_to, [2, 4])) - dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], [])) + dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], []), 1, dispatches_last_retrieved) with mock.patch.multiple(OctopusEnergyApiClient, async_get_electricity_rates=async_mocked_get_electricity_rates): client = OctopusEnergyApiClient("NOT_REAL") @@ -243,7 +243,7 @@ def fire_event(name, metadata): account_info = get_account_info() expected_retrieved_rates = ElectricityRatesCoordinatorResult(current, 1, expected_rates_unsorted) - dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], [])) + dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], []), 1, dispatches_last_retrieved) with mock.patch.multiple(OctopusEnergyApiClient, async_get_electricity_rates=async_mocked_get_electricity_rates): client = OctopusEnergyApiClient("NOT_REAL") @@ -279,9 +279,9 @@ def fire_event(name, metadata): @pytest.mark.asyncio @pytest.mark.parametrize("dispatches_result",[ (None), - (IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, None)), - (IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", None, []))), - (IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], None))), + (IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, None, 1, dispatches_last_retrieved)), + (IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", None, []), 1, dispatches_last_retrieved)), + (IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], None), 1, dispatches_last_retrieved)), ]) async def test_when_dispatches_is_not_defined_and_existing_rates_is_none_then_rates_retrieved(dispatches_result): expected_period_from = (current - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) @@ -366,7 +366,7 @@ def fire_event(name, metadata): account_info = get_account_info() existing_rates = ElectricityRatesCoordinatorResult(period_to - timedelta(days=60), 1, create_rate_data(period_from - timedelta(days=60), period_to - timedelta(days=60), [2, 4])) expected_retrieved_rates = ElectricityRatesCoordinatorResult(current, 1, expected_rates) - dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], [])) + dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], []), 1, dispatches_last_retrieved) with mock.patch.multiple(OctopusEnergyApiClient, async_get_electricity_rates=async_mocked_get_electricity_rates): client = OctopusEnergyApiClient("NOT_REAL") @@ -417,7 +417,7 @@ def fire_event(name, metadata): account_info = get_account_info() existing_rates = ElectricityRatesCoordinatorResult(period_to - timedelta(days=1), 1, create_rate_data(expected_period_from, expected_period_to, [2, 4], default_tariff_code)) expected_retrieved_rates = ElectricityRatesCoordinatorResult(current, 1, existing_rates.rates) - dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], [])) + dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], []), 1, dispatches_last_retrieved) with mock.patch.multiple(OctopusEnergyApiClient, async_get_electricity_rates=async_mocked_get_electricity_rates): client = OctopusEnergyApiClient("NOT_REAL") @@ -474,7 +474,7 @@ def fire_event(name, metadata): account_info = get_account_info() existing_rates = ElectricityRatesCoordinatorResult(period_to - timedelta(days=1), 1, create_rate_data(expected_period_from, expected_period_to, [2, 4], f"{default_tariff_code}-diff")) expected_retrieved_rates = ElectricityRatesCoordinatorResult(current, 1, existing_rates.rates) - dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], [])) + dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], []), 1, dispatches_last_retrieved) with mock.patch.multiple(OctopusEnergyApiClient, async_get_electricity_rates=async_mocked_get_electricity_rates): client = OctopusEnergyApiClient("NOT_REAL") @@ -534,7 +534,7 @@ def fire_event(name, metadata): account_info = get_account_info() existing_rates = ElectricityRatesCoordinatorResult(period_to - timedelta(days=60), 1, create_rate_data(expected_period_from, expected_rates[0]["start"], [1], default_tariff_code)) expected_retrieved_rates = ElectricityRatesCoordinatorResult(current, 1, expected_rates) - dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], [])) + dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], []), 1, dispatches_last_retrieved) with mock.patch.multiple(OctopusEnergyApiClient, async_get_electricity_rates=async_mocked_get_electricity_rates): client = OctopusEnergyApiClient("NOT_REAL") @@ -614,7 +614,7 @@ def fire_event(name, metadata): account_info = get_account_info() existing_rates = ElectricityRatesCoordinatorResult(period_to - timedelta(days=60), 1, create_rate_data(expected_period_from, expected_rates[0]["start"], [1], f"{default_tariff_code}-new")) expected_retrieved_rates = ElectricityRatesCoordinatorResult(current, 1, expected_rates) - dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], [])) + dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], []), 1, dispatches_last_retrieved) with mock.patch.multiple(OctopusEnergyApiClient, async_get_electricity_rates=async_mocked_get_electricity_rates): client = OctopusEnergyApiClient("NOT_REAL") @@ -688,7 +688,7 @@ def fire_event(name, metadata): ) ], [] - )) + ), 1, dispatches_last_retrieved) with mock.patch.multiple(OctopusEnergyApiClient, async_get_electricity_rates=async_mocked_get_electricity_rates): client = OctopusEnergyApiClient("NOT_REAL") @@ -776,7 +776,7 @@ def fire_event(name, metadata): ) ], [] - )) + ), 1, dispatches_last_retrieved) with mock.patch.multiple(OctopusEnergyApiClient, async_get_electricity_rates=async_mocked_get_electricity_rates): client = OctopusEnergyApiClient("NOT_REAL") @@ -841,7 +841,7 @@ def fire_event(name, metadata): account_info = get_account_info() existing_rates = ElectricityRatesCoordinatorResult(period_from, 1, create_rate_data(period_from, period_to, [1, 2, 3, 4])) - dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], [])) + dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], []), 1, dispatches_last_retrieved) with mock.patch.multiple(OctopusEnergyApiClient, async_get_electricity_rates=async_mocked_get_electricity_rates): client = OctopusEnergyApiClient("NOT_REAL") @@ -888,7 +888,7 @@ def fire_event(name, metadata): account_info = get_account_info() existing_rates = ElectricityRatesCoordinatorResult(period_from, 1, create_rate_data(period_from, period_to, [1, 2, 3, 4])) - dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], [])) + dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], []), 1, dispatches_last_retrieved) with mock.patch.multiple(OctopusEnergyApiClient, async_get_electricity_rates=async_mocked_get_electricity_rates): client = OctopusEnergyApiClient("NOT_REAL") @@ -952,7 +952,7 @@ def fire_event(name, metadata): ) ], [] - )) + ), 1, dispatches_last_retrieved) with mock.patch.multiple(OctopusEnergyApiClient, async_get_electricity_rates=async_mocked_get_electricity_rates): client = OctopusEnergyApiClient("NOT_REAL") @@ -1038,7 +1038,7 @@ def fire_event(name, metadata): ) ], [] - )) + ), 1, dispatches_last_retrieved) with mock.patch.multiple(OctopusEnergyApiClient, async_get_electricity_rates=async_mocked_get_electricity_rates): client = OctopusEnergyApiClient("NOT_REAL") @@ -1093,7 +1093,7 @@ def fire_event(name, metadata): ) ], [] - )) + ), 1, dispatches_last_retrieved) with mock.patch.multiple(OctopusEnergyApiClient, async_get_electricity_rates=async_mocked_get_electricity_rates): client = OctopusEnergyApiClient("NOT_REAL") @@ -1200,7 +1200,7 @@ def fire_event(name, metadata): ) ], [] - )) + ), 1, dispatches_last_retrieved) intelligent_device = None with mock.patch.multiple(OctopusEnergyApiClient, async_get_electricity_rates=async_mocked_get_electricity_rates): @@ -1367,7 +1367,7 @@ def fire_event(name, metadata): account_info = get_account_info() expected_retrieved_rates = ElectricityRatesCoordinatorResult(current, 1, expected_rates_unsorted) - dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], [])) + dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], []), 1, dispatches_last_retrieved) with mock.patch.multiple(OctopusEnergyApiClient, async_get_electricity_rates=async_mocked_get_electricity_rates): client = OctopusEnergyApiClient("NOT_REAL") diff --git a/tests/unit/coordinators/test_async_refresh_intelligent_dispatches.py b/tests/unit/coordinators/test_async_refresh_intelligent_dispatches.py index 3e7dec56..94612ec8 100644 --- a/tests/unit/coordinators/test_async_refresh_intelligent_dispatches.py +++ b/tests/unit/coordinators/test_async_refresh_intelligent_dispatches.py @@ -49,7 +49,7 @@ def get_account_info(is_active_agreement = True, active_product_code = product_c } @pytest.mark.asyncio -async def test_when_account_info_is_none_then_existing_settings_returned(): +async def test_when_account_info_is_none_then_existing_dispatches_returned(): expected_dispatches = IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], []) mock_api_called = False async def async_mock_get_intelligent_dispatches(*args, **kwargs): @@ -62,7 +62,7 @@ async def async_merge_dispatch_data(*args, **kwargs): return completed_dispatches account_info = None - existing_settings = IntelligentDispatchesCoordinatorResult(last_retrieved, 1, mock_intelligent_dispatches()) + existing_dispatches = IntelligentDispatchesCoordinatorResult(last_retrieved, 1, mock_intelligent_dispatches(), 1, last_retrieved) with mock.patch.multiple(OctopusEnergyApiClient, async_get_intelligent_dispatches=async_mock_get_intelligent_dispatches): client = OctopusEnergyApiClient("NOT_REAL") @@ -71,12 +71,13 @@ async def async_merge_dispatch_data(*args, **kwargs): client, account_info, intelligent_device, - existing_settings, + existing_dispatches, + False, False, async_merge_dispatch_data ) - assert retrieved_dispatches == existing_settings + assert retrieved_dispatches == existing_dispatches assert mock_api_called == False @pytest.mark.asyncio @@ -93,7 +94,7 @@ async def async_merge_dispatch_data(*args, **kwargs): return completed_dispatches account_info = get_account_info(True, active_product_code="GO-18-06-12") - existing_settings = None + existing_dispatches = None with mock.patch.multiple(OctopusEnergyApiClient, async_get_intelligent_dispatches=async_mock_get_intelligent_dispatches): client = OctopusEnergyApiClient("NOT_REAL") @@ -102,7 +103,8 @@ async def async_merge_dispatch_data(*args, **kwargs): client, account_info, None, - existing_settings, + existing_dispatches, + False, False, async_merge_dispatch_data ) @@ -125,7 +127,7 @@ async def async_merge_dispatch_data(*args, **kwargs): return completed_dispatches account_info = get_account_info(True, active_product_code="GO-18-06-12") - existing_settings = None + existing_dispatches = None with mock.patch.multiple(OctopusEnergyApiClient, async_get_intelligent_dispatches=async_mock_get_intelligent_dispatches): client = OctopusEnergyApiClient("NOT_REAL") @@ -134,7 +136,8 @@ async def async_merge_dispatch_data(*args, **kwargs): client, account_info, intelligent_device, - existing_settings, + existing_dispatches, + False, False, async_merge_dispatch_data ) @@ -156,7 +159,7 @@ async def async_merge_dispatch_data(*args, **kwargs): return completed_dispatches account_info = get_account_info() - existing_settings = None + existing_dispatches = None with mock.patch.multiple(OctopusEnergyApiClient, async_get_intelligent_dispatches=async_mock_get_intelligent_dispatches): client = OctopusEnergyApiClient("NOT_REAL") @@ -165,8 +168,9 @@ async def async_merge_dispatch_data(*args, **kwargs): client, account_info, intelligent_device, - existing_settings, + existing_dispatches, True, + False, async_merge_dispatch_data ) @@ -206,7 +210,7 @@ async def async_merge_dispatch_data(*args, **kwargs): return completed_dispatches account_info = get_account_info() - existing_dispatches = IntelligentDispatchesCoordinatorResult(current - timedelta(minutes=(REFRESH_RATE_IN_MINUTES_INTELLIGENT - 1), seconds=59), 1, mock_intelligent_dispatches()) + existing_dispatches = IntelligentDispatchesCoordinatorResult(current - timedelta(minutes=(REFRESH_RATE_IN_MINUTES_INTELLIGENT - 1), seconds=59), 1, mock_intelligent_dispatches(), 1, last_retrieved) with mock.patch.multiple(OctopusEnergyApiClient, async_get_intelligent_dispatches=async_mock_get_intelligent_dispatches): client = OctopusEnergyApiClient("NOT_REAL") @@ -217,6 +221,7 @@ async def async_merge_dispatch_data(*args, **kwargs): intelligent_device, existing_dispatches, False, + False, async_merge_dispatch_data ) @@ -224,12 +229,12 @@ async def async_merge_dispatch_data(*args, **kwargs): assert retrieved_dispatches == existing_dispatches @pytest.mark.asyncio -@pytest.mark.parametrize("existing_settings",[ +@pytest.mark.parametrize("existing_dispatches",[ (None), - (IntelligentDispatchesCoordinatorResult(last_retrieved, 1, [])), - (IntelligentDispatchesCoordinatorResult(last_retrieved, 1, None)), + (IntelligentDispatchesCoordinatorResult(last_retrieved, 1, [], 1, last_retrieved)), + (IntelligentDispatchesCoordinatorResult(last_retrieved, 1, None, 1, last_retrieved)), ]) -async def test_when_existing_settings_is_none_then_settings_retrieved(existing_settings): +async def test_when_existing_dispatches_is_none_then_settings_retrieved(existing_dispatches): expected_dispatches = IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], []) mock_api_called = False async def async_mock_get_intelligent_dispatches(*args, **kwargs): @@ -242,7 +247,7 @@ async def async_merge_dispatch_data(*args, **kwargs): return completed_dispatches account_info = get_account_info() - expected_retrieved_dispatches = IntelligentDispatchesCoordinatorResult(current, 1, expected_dispatches) + expected_retrieved_dispatches = IntelligentDispatchesCoordinatorResult(current, 1, expected_dispatches, 1, last_retrieved) with mock.patch.multiple(OctopusEnergyApiClient, async_get_intelligent_dispatches=async_mock_get_intelligent_dispatches): client = OctopusEnergyApiClient("NOT_REAL") @@ -251,7 +256,8 @@ async def async_merge_dispatch_data(*args, **kwargs): client, account_info, intelligent_device, - existing_settings, + existing_dispatches, + False, False, async_merge_dispatch_data ) @@ -262,7 +268,7 @@ async def async_merge_dispatch_data(*args, **kwargs): assert mock_api_called == True @pytest.mark.asyncio -async def test_when_existing_settings_is_old_then_settings_retrieved(): +async def test_when_existing_dispatches_is_old_then_settings_retrieved(): expected_dispatches = IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], []) mock_api_called = False async def async_mock_get_intelligent_dispatches(*args, **kwargs): @@ -275,8 +281,8 @@ async def async_merge_dispatch_data(*args, **kwargs): return completed_dispatches account_info = get_account_info() - existing_settings = IntelligentDispatchesCoordinatorResult(last_retrieved - timedelta(days=60), 1, mock_intelligent_dispatches()) - expected_retrieved_dispatches = IntelligentDispatchesCoordinatorResult(current, 1, expected_dispatches) + existing_dispatches = IntelligentDispatchesCoordinatorResult(last_retrieved - timedelta(days=60), 1, mock_intelligent_dispatches(), 1, last_retrieved) + expected_retrieved_dispatches = IntelligentDispatchesCoordinatorResult(current, 1, expected_dispatches, 1, last_retrieved) with mock.patch.multiple(OctopusEnergyApiClient, async_get_intelligent_dispatches=async_mock_get_intelligent_dispatches): client = OctopusEnergyApiClient("NOT_REAL") @@ -285,7 +291,8 @@ async def async_merge_dispatch_data(*args, **kwargs): client, account_info, intelligent_device, - existing_settings, + existing_dispatches, + False, False, async_merge_dispatch_data ) @@ -297,7 +304,7 @@ async def async_merge_dispatch_data(*args, **kwargs): assert mock_api_called == True @pytest.mark.asyncio -async def test_when_settings_not_retrieved_then_existing_settings_returned(): +async def test_when_settings_not_retrieved_then_existing_dispatches_returned(): mock_api_called = False async def async_mock_get_intelligent_dispatches(*args, **kwargs): nonlocal mock_api_called @@ -309,7 +316,7 @@ async def async_merge_dispatch_data(*args, **kwargs): return completed_dispatches account_info = get_account_info() - existing_settings = IntelligentDispatchesCoordinatorResult(last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], [])) + existing_dispatches = IntelligentDispatchesCoordinatorResult(last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], []), 1, last_retrieved) with mock.patch.multiple(OctopusEnergyApiClient, async_get_intelligent_dispatches=async_mock_get_intelligent_dispatches): client = OctopusEnergyApiClient("NOT_REAL") @@ -318,21 +325,22 @@ async def async_merge_dispatch_data(*args, **kwargs): client, account_info, intelligent_device, - existing_settings, + existing_dispatches, + False, False, async_merge_dispatch_data ) assert retrieved_dispatches is not None - assert retrieved_dispatches.next_refresh == existing_settings.next_refresh + timedelta(minutes=1) - assert retrieved_dispatches.last_evaluated == existing_settings.last_evaluated - assert retrieved_dispatches.dispatches == existing_settings.dispatches - assert retrieved_dispatches.request_attempts == existing_settings.request_attempts + 1 + assert retrieved_dispatches.next_refresh == existing_dispatches.next_refresh + timedelta(minutes=1) + assert retrieved_dispatches.last_evaluated == existing_dispatches.last_evaluated + assert retrieved_dispatches.dispatches == existing_dispatches.dispatches + assert retrieved_dispatches.request_attempts == existing_dispatches.request_attempts + 1 assert mock_api_called == True @pytest.mark.asyncio -async def test_when_exception_raised_then_existing_settings_returned_and_exception_captured(): +async def test_when_exception_raised_then_existing_dispatches_returned_and_exception_captured(): mock_api_called = False raised_exception = RequestException("foo", []) async def async_mock_get_intelligent_dispatches(*args, **kwargs): @@ -345,7 +353,7 @@ async def async_merge_dispatch_data(*args, **kwargs): return completed_dispatches account_info = get_account_info() - existing_settings = IntelligentDispatchesCoordinatorResult(last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], [])) + existing_dispatches = IntelligentDispatchesCoordinatorResult(last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], []), 1, last_retrieved) with mock.patch.multiple(OctopusEnergyApiClient, async_get_intelligent_dispatches=async_mock_get_intelligent_dispatches): client = OctopusEnergyApiClient("NOT_REAL") @@ -354,16 +362,92 @@ async def async_merge_dispatch_data(*args, **kwargs): client, account_info, intelligent_device, - existing_settings, + existing_dispatches, + False, False, async_merge_dispatch_data ) assert retrieved_dispatches is not None - assert retrieved_dispatches.next_refresh == existing_settings.next_refresh + timedelta(minutes=1) - assert retrieved_dispatches.last_evaluated == existing_settings.last_evaluated - assert retrieved_dispatches.dispatches == existing_settings.dispatches - assert retrieved_dispatches.request_attempts == existing_settings.request_attempts + 1 + assert retrieved_dispatches.next_refresh == existing_dispatches.next_refresh + timedelta(minutes=1) + assert retrieved_dispatches.last_evaluated == existing_dispatches.last_evaluated + assert retrieved_dispatches.dispatches == existing_dispatches.dispatches + assert retrieved_dispatches.request_attempts == existing_dispatches.request_attempts + 1 assert retrieved_dispatches.last_error == raised_exception - assert mock_api_called == True \ No newline at end of file + assert mock_api_called == True + +@pytest.mark.asyncio +async def test_when_requests_reached_for_hour_and_due_to_be_reset_then_settings_retrieved(): + expected_dispatches = IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], []) + mock_api_called = False + async def async_mock_get_intelligent_dispatches(*args, **kwargs): + nonlocal mock_api_called + mock_api_called = True + return expected_dispatches + + async def async_merge_dispatch_data(*args, **kwargs): + account_id, completed_dispatches = args + return completed_dispatches + + account_info = get_account_info() + existing_dispatches = IntelligentDispatchesCoordinatorResult(last_retrieved - timedelta(days=60), 1, mock_intelligent_dispatches(), 20, current - timedelta(hours=1)) + expected_retrieved_dispatches = IntelligentDispatchesCoordinatorResult(current, 1, expected_dispatches, 1, last_retrieved) + + with mock.patch.multiple(OctopusEnergyApiClient, async_get_intelligent_dispatches=async_mock_get_intelligent_dispatches): + client = OctopusEnergyApiClient("NOT_REAL") + retrieved_dispatches: IntelligentDispatchesCoordinatorResult = await async_refresh_intelligent_dispatches( + current, + client, + account_info, + intelligent_device, + existing_dispatches, + False, + False, + async_merge_dispatch_data + ) + + assert retrieved_dispatches is not None + assert retrieved_dispatches.next_refresh == current + timedelta(minutes=REFRESH_RATE_IN_MINUTES_INTELLIGENT) + assert retrieved_dispatches.last_evaluated == expected_retrieved_dispatches.last_evaluated + assert retrieved_dispatches.dispatches == expected_retrieved_dispatches.dispatches + assert mock_api_called == True + +@pytest.mark.asyncio +async def test_when_requests_reached_for_hour_and_not_due_to_be_reset_then_existing_dispatches_returned_with_error(): + expected_dispatches = IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], []) + mock_api_called = False + async def async_mock_get_intelligent_dispatches(*args, **kwargs): + nonlocal mock_api_called + mock_api_called = True + return expected_dispatches + + async def async_merge_dispatch_data(*args, **kwargs): + account_id, completed_dispatches = args + return completed_dispatches + + account_info = get_account_info() + existing_dispatches = IntelligentDispatchesCoordinatorResult(last_retrieved - timedelta(days=60), 1, mock_intelligent_dispatches(), 20, current) + + with mock.patch.multiple(OctopusEnergyApiClient, async_get_intelligent_dispatches=async_mock_get_intelligent_dispatches): + client = OctopusEnergyApiClient("NOT_REAL") + retrieved_dispatches = await async_refresh_intelligent_dispatches( + current, + client, + account_info, + intelligent_device, + existing_dispatches, + False, + False, + async_merge_dispatch_data + ) + + assert retrieved_dispatches is not None + assert retrieved_dispatches.next_refresh == existing_dispatches.next_refresh + assert retrieved_dispatches.last_evaluated == existing_dispatches.last_evaluated + assert retrieved_dispatches.dispatches == existing_dispatches.dispatches + assert retrieved_dispatches.request_attempts == existing_dispatches.request_attempts + assert retrieved_dispatches.requests_current_hour == existing_dispatches.requests_current_hour + assert retrieved_dispatches.requests_current_hour_last_reset == existing_dispatches.requests_current_hour_last_reset + assert retrieved_dispatches.last_error == f"Maximum requests of 20/hour reached. Will reset after {current + timedelta(hours=1)}" + assert mock_api_called == False \ No newline at end of file