Skip to content

Commit b803abb

Browse files
feat: Added service/action for retrieveing intelligent dispatches applicable at a given point in time via local data. This is useful to determine why off peak or dispatch sensors might have turned on (4.5 hours dev time)
1 parent 4ae4e00 commit b803abb

17 files changed

+974
-114
lines changed

_docs/services.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,25 @@ Refreshes intelligent dispatches for a given account.
308308

309309
For an automation example, please refer to the available [blueprint](./blueprints.md#manual-intelligent-dispatch-refreshes).
310310

311+
### get_point_in_time_intelligent_dispatch_history
312+
313+
Retrieve the intelligent dispatch history which was active for a given point in time based on up to the last 48 hours of intelligent dispatches that have been captured locally. This can be used to determine why [is dispatching](./entities/intelligent.md#is-dispatching) or [off peak](./entities/electricity.md#off-peak) might have turned on during a certain time period.
314+
315+
!!! info
316+
317+
The OE API doesn't provide historic intelligent dispatch information, so this information is stored locally as it changes. Therefore depending on how often your dispatch information refreshes, it can take a while for data to become available.
318+
319+
!!! note
320+
321+
The data that powers this service is available at `config/.storage/octopus_energy.intelligent_dispatches_history_{{DEVICE_ID}}`
322+
323+
324+
| Attribute | Optional | Description |
325+
| ------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------- |
326+
| `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_{{DEVICE_ID}}_intelligent_dispatching`). |
327+
| `data.point_in_time` | `no` | The point in time to get the historic dispatch information that was active at the time.
328+
329+
311330
## Miscellaneous
312331

313332
### octopus_energy.purge_invalid_external_statistic_ids

custom_components/octopus_energy/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from .storage.intelligent_device import async_load_cached_intelligent_devices, async_save_cached_intelligent_devices
3434
from .storage.rate_weightings import async_load_cached_rate_weightings
3535
from .storage.intelligent_dispatches import async_load_cached_intelligent_dispatches
36+
from .storage.intelligent_dispatches_history import IntelligentDispatchesHistory, async_load_cached_intelligent_dispatches_history
3637
from .api_client.intelligent_dispatches import IntelligentDispatches
3738
from .discovery import DiscoveryManager
3839
from .coordinators.intelligent_device import IntelligentDeviceCoordinatorResult, async_setup_intelligent_devices_coordinator
@@ -547,13 +548,15 @@ async def async_register_intelligent_devices(hass, config: dict, now: datetime,
547548
await async_save_cached_intelligent_devices(hass, account_id, intelligent_devices)
548549

549550
for intelligent_device in intelligent_devices:
550-
551551
cached_dispatches = await async_load_cached_intelligent_dispatches(hass, account_id, intelligent_device.id)
552+
intelligent_dispatches_history = await async_load_cached_intelligent_dispatches_history(hass, intelligent_device.id)
553+
552554
if cached_dispatches is not None:
553555
hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DISPATCHES][intelligent_device.id] = IntelligentDispatchesCoordinatorResult(
554556
now - timedelta(hours=1),
555557
1,
556558
cached_dispatches,
559+
intelligent_dispatches_history,
557560
0,
558561
now - timedelta(hours=1)
559562
)
@@ -597,6 +600,7 @@ async def async_register_intelligent_devices(hass, config: dict, now: datetime,
597600
now - timedelta(hours=1),
598601
1,
599602
IntelligentDispatches(None, [], []),
603+
IntelligentDispatchesHistory([]),
600604
0,
601605
now - timedelta(hours=1)
602606
)

custom_components/octopus_energy/binary_sensor.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
import voluptuous as vol
44

5-
from homeassistant.core import HomeAssistant
5+
from homeassistant.core import HomeAssistant, SupportsResponse
66
from homeassistant.helpers import config_validation as cv, entity_platform, issue_registry as ir
77
from homeassistant.util.dt import (utcnow)
8+
import homeassistant.helpers.config_validation as cv
89

910
from .electricity.off_peak import OctopusEnergyElectricityOffPeak
1011
from .octoplus.saving_sessions import OctopusEnergySavingSessions
@@ -225,8 +226,9 @@ def get_intelligent_entities(hass, account_id: str, config: dict):
225226
for intelligent_device in intelligent_devices:
226227

227228
if intelligent_device.device_type == INTELLIGENT_DEVICE_KIND_ELECTRIC_VEHICLES or intelligent_device.device_type == INTELLIGENT_DEVICE_KIND_ELECTRIC_VEHICLE_CHARGERS:
229+
230+
platform = entity_platform.async_get_current_platform()
228231
if (manually_refresh_dispatches):
229-
platform = entity_platform.async_get_current_platform()
230232
platform.async_register_entity_service(
231233
"refresh_intelligent_dispatches",
232234
vol.All(
@@ -238,6 +240,20 @@ def get_intelligent_entities(hass, account_id: str, config: dict):
238240
"async_refresh_dispatches"
239241
)
240242

243+
platform.async_register_entity_service(
244+
"get_point_in_time_intelligent_dispatch_history",
245+
vol.All(
246+
cv.make_entity_service_schema(
247+
{
248+
vol.Required("point_in_time"): cv.datetime
249+
},
250+
extra=vol.ALLOW_EXTRA,
251+
),
252+
),
253+
"async_get_point_in_time_intelligent_dispatch_history",
254+
supports_response=SupportsResponse.ONLY
255+
)
256+
241257
coordinator = hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DISPATCHES_COORDINATOR.format(intelligent_device.id)]
242258
entities.append(OctopusEnergyIntelligentDispatching(hass, coordinator, intelligent_device, account_id, intelligent_rate_mode, manually_refresh_dispatches))
243259

custom_components/octopus_energy/coordinators/intelligent_dispatches.py

Lines changed: 38 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@
2828
from ..api_client.intelligent_device import IntelligentDevice
2929
from ..storage.intelligent_dispatches import async_save_cached_intelligent_dispatches
3030

31-
from ..intelligent import clean_previous_dispatches, has_intelligent_tariff, mock_intelligent_dispatches
31+
from ..intelligent import clean_intelligent_dispatch_history, clean_previous_dispatches, has_dispatches_changed, has_intelligent_tariff, mock_intelligent_dispatches
3232
from ..coordinators.intelligent_device import IntelligentDeviceCoordinatorResult
33+
from ..storage.intelligent_dispatches_history import IntelligentDispatchesHistory, async_save_cached_intelligent_dispatches_history
3334

3435
_LOGGER = logging.getLogger(__name__)
3536

@@ -71,38 +72,17 @@ async def refresh_dispatches(self):
7172

7273
class IntelligentDispatchesCoordinatorResult(BaseCoordinatorResult):
7374
dispatches: IntelligentDispatches
75+
history: IntelligentDispatchesHistory
7476
requests_current_hour: int
7577
requests_current_hour_last_reset: datetime
7678

77-
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):
79+
def __init__(self, last_evaluated: datetime, request_attempts: int, dispatches: IntelligentDispatches, history: IntelligentDispatchesHistory, requests_current_hour: int, requests_current_hour_last_reset: datetime, last_error: Exception | None = None):
7880
super().__init__(last_evaluated, request_attempts, REFRESH_RATE_IN_MINUTES_INTELLIGENT, None, last_error)
7981
self.dispatches = dispatches
82+
self.history = history
8083
self.requests_current_hour = requests_current_hour
8184
self.requests_current_hour_last_reset = requests_current_hour_last_reset
8285

83-
def has_dispatch_items_changed(existing_dispatches: list[SimpleIntelligentDispatchItem], new_dispatches: list[SimpleIntelligentDispatchItem]):
84-
if len(existing_dispatches) != len(new_dispatches):
85-
return True
86-
87-
if len(existing_dispatches) > 0:
88-
for i in range(0, len(existing_dispatches)):
89-
if (existing_dispatches[i].start != new_dispatches[i].start or
90-
existing_dispatches[i].end != new_dispatches[i].end):
91-
return True
92-
93-
return False
94-
95-
def has_dispatches_changed(existing_dispatches: IntelligentDispatches, new_dispatches: IntelligentDispatches):
96-
return (
97-
existing_dispatches.current_state != new_dispatches.current_state or
98-
len(existing_dispatches.completed) != len(new_dispatches.completed) or
99-
has_dispatch_items_changed(existing_dispatches.completed, new_dispatches.completed) or
100-
len(existing_dispatches.planned) != len(new_dispatches.planned) or
101-
has_dispatch_items_changed(existing_dispatches.planned, new_dispatches.planned) or
102-
len(existing_dispatches.started) != len(new_dispatches.started) or
103-
has_dispatch_items_changed(existing_dispatches.started, new_dispatches.started)
104-
)
105-
10686
def merge_started_dispatches(current: datetime,
10787
current_state: str,
10888
started_dispatches: list[SimpleIntelligentDispatchItem],
@@ -165,6 +145,7 @@ async def async_retrieve_intelligent_dispatches(
165145
existing_intelligent_dispatches_result.last_evaluated,
166146
existing_intelligent_dispatches_result.request_attempts,
167147
existing_intelligent_dispatches_result.dispatches,
148+
existing_intelligent_dispatches_result.history,
168149
existing_intelligent_dispatches_result.requests_current_hour,
169150
existing_intelligent_dispatches_result.requests_current_hour_last_reset,
170151
last_error=error
@@ -180,6 +161,7 @@ async def async_retrieve_intelligent_dispatches(
180161
existing_intelligent_dispatches_result.last_evaluated,
181162
existing_intelligent_dispatches_result.request_attempts,
182163
existing_intelligent_dispatches_result.dispatches,
164+
existing_intelligent_dispatches_result.history,
183165
existing_intelligent_dispatches_result.requests_current_hour,
184166
existing_intelligent_dispatches_result.requests_current_hour_last_reset,
185167
last_error=error
@@ -216,15 +198,20 @@ async def async_retrieve_intelligent_dispatches(
216198

217199
dispatches.completed = clean_previous_dispatches(current,
218200
(existing_intelligent_dispatches_result.dispatches.completed if existing_intelligent_dispatches_result is not None and existing_intelligent_dispatches_result.dispatches is not None and existing_intelligent_dispatches_result.dispatches.completed is not None else []) + dispatches.completed)
201+
202+
new_history = clean_intelligent_dispatch_history(current,
203+
dispatches,
204+
existing_intelligent_dispatches_result.history.history if existing_intelligent_dispatches_result is not None else [])
219205

220-
return IntelligentDispatchesCoordinatorResult(current, 1, dispatches, requests_current_hour + 1, requests_last_reset)
206+
return IntelligentDispatchesCoordinatorResult(current, 1, dispatches, IntelligentDispatchesHistory(new_history), requests_current_hour + 1, requests_last_reset)
221207

222208
result = None
223209
if (existing_intelligent_dispatches_result is not None):
224210
result = IntelligentDispatchesCoordinatorResult(
225211
existing_intelligent_dispatches_result.last_evaluated,
226212
existing_intelligent_dispatches_result.request_attempts + 1,
227213
existing_intelligent_dispatches_result.dispatches,
214+
existing_intelligent_dispatches_result.history,
228215
existing_intelligent_dispatches_result.requests_current_hour + 1,
229216
existing_intelligent_dispatches_result.requests_current_hour_last_reset,
230217
last_error=raised_exception
@@ -234,7 +221,15 @@ async def async_retrieve_intelligent_dispatches(
234221
_LOGGER.warning(f"Failed to retrieve new dispatches - using cached dispatches. See diagnostics sensor for more information.")
235222
else:
236223
# We want to force into our fallback mode
237-
result = IntelligentDispatchesCoordinatorResult(current - timedelta(minutes=REFRESH_RATE_IN_MINUTES_INTELLIGENT), 2, None, requests_current_hour, requests_last_reset, last_error=raised_exception)
224+
result = IntelligentDispatchesCoordinatorResult(
225+
current - timedelta(minutes=REFRESH_RATE_IN_MINUTES_INTELLIGENT),
226+
2,
227+
None,
228+
IntelligentDispatchesHistory([]),
229+
requests_current_hour,
230+
requests_last_reset,
231+
last_error=raised_exception
232+
)
238233
_LOGGER.warning(f"Failed to retrieve new dispatches. See diagnostics sensor for more information.")
239234

240235
return result
@@ -253,6 +248,7 @@ async def async_refresh_intelligent_dispatches(
253248
is_manual_refresh: bool,
254249
planned_dispatches_supported: bool,
255250
async_save_dispatches: Callable[[IntelligentDispatches], Awaitable[list]],
251+
async_save_dispatches_history: Callable[[IntelligentDispatchesHistory], Awaitable[list]],
256252
):
257253
result = await async_retrieve_intelligent_dispatches(
258254
current,
@@ -282,10 +278,17 @@ async def async_refresh_intelligent_dispatches(
282278
existing_intelligent_dispatches_result.dispatches is None or
283279
has_dispatches_changed(existing_intelligent_dispatches_result.dispatches, result.dispatches)):
284280
await async_save_dispatches(result.dispatches)
281+
await async_save_dispatches_history(result.history)
285282

286283
return result
287284

288-
async def async_setup_intelligent_dispatches_coordinator(hass, account_id: str, device_id: str, mock_intelligent_data: bool, manual_dispatch_refreshes: bool, planned_dispatches_supported: bool):
285+
async def async_setup_intelligent_dispatches_coordinator(
286+
hass,
287+
account_id: str,
288+
device_id: str,
289+
mock_intelligent_data: bool,
290+
manual_dispatch_refreshes: bool,
291+
planned_dispatches_supported: bool):
289292
async def async_update_intelligent_dispatches_data(is_manual_refresh = False):
290293
"""Fetch data from API endpoint."""
291294
# Request our account data to be refreshed
@@ -304,17 +307,22 @@ async def async_update_intelligent_dispatches_data(is_manual_refresh = False):
304307
client: OctopusEnergyApiClient = hass.data[DOMAIN][account_id][DATA_CLIENT]
305308
account_result = hass.data[DOMAIN][account_id][DATA_ACCOUNT]
306309
account_info = account_result.account if account_result is not None else None
310+
existing_intelligent_dispatches_result = (hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DISPATCHES][device_id]
311+
if DATA_INTELLIGENT_DISPATCHES in hass.data[DOMAIN][account_id] and
312+
device_id in hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DISPATCHES]
313+
else None)
307314

308315
hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DISPATCHES][device_id] = await async_refresh_intelligent_dispatches(
309316
current,
310317
client,
311318
account_info,
312319
intelligent_device,
313-
hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DISPATCHES][device_id] if DATA_INTELLIGENT_DISPATCHES in hass.data[DOMAIN][account_id] and device_id in hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DISPATCHES] else None,
320+
existing_intelligent_dispatches_result,
314321
mock_intelligent_data,
315322
is_manual_refresh,
316323
planned_dispatches_supported,
317-
lambda dispatches: async_save_cached_intelligent_dispatches(hass, device_id, dispatches)
324+
lambda dispatches: async_save_cached_intelligent_dispatches(hass, device_id, dispatches),
325+
lambda history: async_save_cached_intelligent_dispatches_history(hass, device_id, history)
318326
)
319327

320328
return hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DISPATCHES][device_id]

custom_components/octopus_energy/icons.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"boost_heat_pump_zone": "mdi:thermometer-plus",
1515
"boost_water_heater": "mdi:thermometer-plus",
1616
"set_heat_pump_flow_temp_config": "mdi:heat-pump",
17-
"refresh_intelligent_dispatches": "mdi:refresh"
17+
"refresh_intelligent_dispatches": "mdi:refresh",
18+
"get_point_in_time_intelligent_dispatch_history": "mdi:history"
1819
}
1920
}

custom_components/octopus_energy/intelligent/__init__.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from ..const import CONFIG_MAIN_INTELLIGENT_RATE_MODE_PLANNED_AND_STARTED_DISPATCHES, INTELLIGENT_DEVICE_KIND_ELECTRIC_VEHICLE_CHARGERS, INTELLIGENT_DEVICE_KIND_ELECTRIC_VEHICLES, INTELLIGENT_SOURCE_BUMP_CHARGE_OPTIONS, INTELLIGENT_SOURCE_SMART_CHARGE_OPTIONS, REFRESH_RATE_IN_MINUTES_INTELLIGENT
1010

11+
from ..storage.intelligent_dispatches_history import IntelligentDispatchesHistory, IntelligentDispatchesHistoryItem
1112
from ..api_client.intelligent_settings import IntelligentSettings
1213
from ..api_client.intelligent_dispatches import IntelligentDispatchItem, IntelligentDispatches, SimpleIntelligentDispatchItem
1314
from ..api_client.intelligent_device import IntelligentDevice
@@ -266,6 +267,38 @@ def clean_previous_dispatches(time: datetime, dispatches: list[IntelligentDispat
266267

267268
return list(new_dispatches.values())
268269

270+
def clean_intelligent_dispatch_history(time: datetime,
271+
dispatches: IntelligentDispatches,
272+
history: list[IntelligentDispatchesHistoryItem]) -> list[IntelligentDispatchItem]:
273+
history.sort(key = lambda x: x.timestamp)
274+
275+
new_history: list[IntelligentDispatchesHistoryItem] = []
276+
previous_history_item: IntelligentDispatchesHistoryItem | None = None
277+
min_time = time - timedelta(days=2)
278+
279+
for history_item in history:
280+
281+
if history_item.timestamp >= min_time:
282+
# Ensure we have one record before the minimum stored time so we know what we had at the start
283+
if (len(new_history) == 0 and previous_history_item is not None):
284+
new_history.append(previous_history_item)
285+
286+
new_history.append(history_item)
287+
288+
previous_history_item = history_item
289+
290+
# Ensure we have one record before the minimum stored time so we know what we had at the start
291+
if (len(new_history) == 0 and previous_history_item is not None):
292+
new_history.append(previous_history_item)
293+
294+
if len(new_history) < 1 or has_dispatches_changed(new_history[-1].dispatches, dispatches):
295+
new_history.append(IntelligentDispatchesHistoryItem(
296+
time,
297+
dispatches)
298+
)
299+
300+
return new_history
301+
269302
def dictionary_list_to_dispatches(dispatches: list):
270303
items = []
271304
if (dispatches is not None):
@@ -370,4 +403,37 @@ def device_type_to_friendly_string(device_type: str) -> str:
370403
elif device_type == INTELLIGENT_DEVICE_KIND_ELECTRIC_VEHICLES:
371404
return "Electric Vehicle"
372405
else:
373-
return device_type
406+
return device_type
407+
408+
def has_dispatch_items_changed(existing_dispatches: list[SimpleIntelligentDispatchItem], new_dispatches: list[SimpleIntelligentDispatchItem]):
409+
if len(existing_dispatches) != len(new_dispatches):
410+
return True
411+
412+
if len(existing_dispatches) > 0:
413+
for i in range(0, len(existing_dispatches)):
414+
if (existing_dispatches[i].start != new_dispatches[i].start or
415+
existing_dispatches[i].end != new_dispatches[i].end):
416+
return True
417+
418+
return False
419+
420+
def has_dispatches_changed(existing_dispatches: IntelligentDispatches, new_dispatches: IntelligentDispatches):
421+
return (
422+
existing_dispatches.current_state != new_dispatches.current_state or
423+
has_dispatch_items_changed(existing_dispatches.completed, new_dispatches.completed) or
424+
has_dispatch_items_changed(existing_dispatches.planned, new_dispatches.planned) or
425+
has_dispatch_items_changed(existing_dispatches.started, new_dispatches.started)
426+
)
427+
428+
def get_applicable_intelligent_dispatch_history(history: IntelligentDispatchesHistory, time: datetime) -> IntelligentDispatchesHistoryItem | None:
429+
if history is None or history.history is None or len(history.history) == 0:
430+
return None
431+
432+
applicable_history_item: IntelligentDispatchesHistoryItem | None = None
433+
for history_item in history.history:
434+
if history_item.timestamp <= time:
435+
applicable_history_item = history_item
436+
else:
437+
break
438+
439+
return applicable_history_item

0 commit comments

Comments
 (0)