Skip to content

Commit 930ab11

Browse files
Next release (#1349)
2 parents c30b0f8 + 6e20a4a commit 930ab11

File tree

8 files changed

+123
-22
lines changed

8 files changed

+123
-22
lines changed

_docs/entities/intelligent.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ This sensor is used to determine if you're currently in a planned dispatch perio
3030
|-----------|------|-------------|
3131
| `planned_dispatches` | `array` | An array of the dispatches that are currently planned by Octopus Energy. |
3232
| `completed_dispatches` | `array` | An array of the dispatches that have been completed by Octopus Energy. This will only store up to the last 3 days worth of completed dispatches. |
33-
| `started_dispatches` | `array` | An array of the dispatches that have been planned by Octopus Energy and upon API refresh are still planned when the current 30 minute period started and is not in a boosting state. A planned dispatch will be added one 30 minute period at a time. This will only store up to the last 3 days worth of started dispatches. This is used to determine historic off peak rates. For example if you have a planned dispatch of `2025-04-01T10:00:00`-`2025-04-01T11:00:00`, at `2025-04-01T10:01:00` if the planned dispatch is still available the period of `2025-04-01T10:00:00`-`2025-04-01T10:30:00` will be added. |
33+
| `started_dispatches` | `array` | An array of the dispatches that have been planned by Octopus Energy and upon API refresh are still planned when the current 30 minute period has started, is not in a boosting state and the data has been refreshed within the last 3 minutes. A planned dispatch will be added one 30 minute period at a time. This will only store up to the last 3 days worth of started dispatches. This is used to determine historic off peak rates. For example if you have a planned dispatch of `2025-04-01T10:00:00`-`2025-04-01T11:00:00`, at `2025-04-01T10:01:00` if the planned dispatch is still available the period of `2025-04-01T10:00:00`-`2025-04-01T10:30:00` will be added. |
3434
| `provider` | `string` | The provider of the intelligent features |
3535
| `vehicle_battery_size_in_kwh` | `float` | The size of the target vehicle battery in kWh. |
3636
| `charge_point_power_in_kw` | `float` | The power of the charge point battery in kW. |

_docs/setup/cost_tracker.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ This is the entity whose consumption should be tracked and the cost calculated a
2424

2525
### Tracked entity state is accumulative
2626

27-
This should be true if the tracked entity's state increases over time (true) or if it's the difference between updates (false).
27+
This should be true if the tracked entity's state increases over time (true) or if it's the raw value (false).
2828

2929
!!! info
3030

custom_components/octopus_energy/api_client/intelligent_dispatches.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,9 @@ def __init__(
8787
def to_dict(self):
8888
return {
8989
"current_state": self.current_state,
90-
"planned": list(map(lambda x: x.to_dict(), self.planned)),
91-
"completed": list(map(lambda x: x.to_dict(), self.completed)),
92-
"started": list(map(lambda x: x.to_dict(), self.started)),
90+
"planned": list(map(lambda x: x.to_dict(), self.planned)) if self.planned is not None else [],
91+
"completed": list(map(lambda x: x.to_dict(), self.completed)) if self.completed is not None else [],
92+
"started": list(map(lambda x: x.to_dict(), self.started)) if self.started is not None else [],
9393
}
9494

9595
def from_dict(data):

custom_components/octopus_energy/coordinators/electricity_rates.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ async def async_refresh_electricity_rates_data(
124124
dispatches_result.dispatches.started,
125125
intelligent_rate_mode)
126126

127-
_LOGGER.debug(f"Rates adjusted: {new_rates}; dispatches: {dispatches_result.dispatches}")
127+
_LOGGER.debug(f"Rates adjusted: {new_rates}; dispatches: {dispatches_result.dispatches.to_dict()}")
128128

129129
# Sort our rates again _just in case_
130130
new_rates.sort(key=lambda rate: (rate["start"].timestamp(), rate["start"].fold))
@@ -190,7 +190,7 @@ async def async_refresh_electricity_rates_data(
190190
dispatches_result.dispatches.started,
191191
intelligent_rate_mode)
192192

193-
_LOGGER.debug(f"Rates adjusted: {new_rates}; dispatches: {dispatches_result.dispatches}")
193+
_LOGGER.debug(f"Rates adjusted: {new_rates}; dispatches: {dispatches_result.dispatches.to_dict()}")
194194

195195
# Sort our rates again _just in case_
196196
new_rates.sort(key=lambda rate: (rate["start"].timestamp(), rate["start"].fold))

custom_components/octopus_energy/coordinators/intelligent_dispatches.py

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,10 @@ def has_dispatches_changed(existing_dispatches: IntelligentDispatches, new_dispa
109109
)
110110
)
111111

112-
def merge_started_dispatches(current: datetime, current_state: str, started_dispatches: list[SimpleIntelligentDispatchItem], planned_dispatches: list[IntelligentDispatchItem]):
112+
def merge_started_dispatches(current: datetime,
113+
current_state: str,
114+
started_dispatches: list[SimpleIntelligentDispatchItem],
115+
planned_dispatches: list[IntelligentDispatchItem]):
113116
new_started_dispatches = clean_previous_dispatches(current, started_dispatches)
114117

115118
if current_state == "SMART_CONTROL_IN_PROGRESS":
@@ -137,7 +140,7 @@ def merge_started_dispatches(current: datetime, current_state: str, started_disp
137140

138141
return new_started_dispatches
139142

140-
async def async_refresh_intelligent_dispatches(
143+
async def async_retrieve_intelligent_dispatches(
141144
current: datetime,
142145
client: OctopusEnergyApiClient,
143146
account_info,
@@ -146,7 +149,6 @@ async def async_refresh_intelligent_dispatches(
146149
is_data_mocked: bool,
147150
is_manual_refresh: bool,
148151
planned_dispatches_supported: bool,
149-
async_save_dispatches: Callable[[str, IntelligentDispatches], Awaitable[list]],
150152
):
151153
requests_current_hour = existing_intelligent_dispatches_result.requests_current_hour if existing_intelligent_dispatches_result is not None else 0
152154
requests_last_reset = existing_intelligent_dispatches_result.requests_current_hour_last_reset if existing_intelligent_dispatches_result is not None else current
@@ -208,18 +210,8 @@ async def async_refresh_intelligent_dispatches(
208210
dispatches.planned.clear()
209211

210212
if dispatches is not None:
211-
dispatches.completed = clean_previous_dispatches(utcnow(), (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)
212-
dispatches.started = merge_started_dispatches(current,
213-
dispatches.current_state,
214-
existing_intelligent_dispatches_result.dispatches.started
215-
if existing_intelligent_dispatches_result is not None and existing_intelligent_dispatches_result.dispatches is not None and existing_intelligent_dispatches_result.dispatches.started is not None
216-
else [],
217-
dispatches.planned)
218-
219-
if (existing_intelligent_dispatches_result is None or
220-
existing_intelligent_dispatches_result.dispatches is None or
221-
has_dispatches_changed(existing_intelligent_dispatches_result.dispatches, dispatches)):
222-
await async_save_dispatches(account_id, dispatches)
213+
dispatches.completed = clean_previous_dispatches(current,
214+
(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)
223215

224216
return IntelligentDispatchesCoordinatorResult(current, 1, dispatches, requests_current_hour + 1, requests_last_reset)
225217

@@ -245,6 +237,48 @@ async def async_refresh_intelligent_dispatches(
245237

246238
return existing_intelligent_dispatches_result
247239

240+
async def async_refresh_intelligent_dispatches(
241+
current: datetime,
242+
client: OctopusEnergyApiClient,
243+
account_info,
244+
intelligent_device: IntelligentDevice,
245+
existing_intelligent_dispatches_result: IntelligentDispatchesCoordinatorResult,
246+
is_data_mocked: bool,
247+
is_manual_refresh: bool,
248+
planned_dispatches_supported: bool,
249+
async_save_dispatches: Callable[[str, IntelligentDispatches], Awaitable[list]],
250+
):
251+
result = await async_retrieve_intelligent_dispatches(
252+
current,
253+
client,
254+
account_info,
255+
intelligent_device,
256+
existing_intelligent_dispatches_result,
257+
is_data_mocked,
258+
is_manual_refresh,
259+
planned_dispatches_supported
260+
)
261+
262+
if result is not None and result.dispatches is not None:
263+
if result.last_retrieved < (current - timedelta(minutes=REFRESH_RATE_IN_MINUTES_INTELLIGENT)):
264+
_LOGGER.debug('Skipping started dispatches processing as data has not been refreshed recently')
265+
# If we haven't refreshed recently, then we can't accurately process started dispatches
266+
return result
267+
268+
result.dispatches.started = merge_started_dispatches(current,
269+
result.dispatches.current_state,
270+
existing_intelligent_dispatches_result.dispatches.started
271+
if existing_intelligent_dispatches_result is not None and existing_intelligent_dispatches_result.dispatches is not None and existing_intelligent_dispatches_result.dispatches.started is not None
272+
else [],
273+
result.dispatches.planned)
274+
275+
if (existing_intelligent_dispatches_result is None or
276+
existing_intelligent_dispatches_result.dispatches is None or
277+
has_dispatches_changed(existing_intelligent_dispatches_result.dispatches, result.dispatches)):
278+
await async_save_dispatches(account_info["id"], result.dispatches)
279+
280+
return result
281+
248282
async def async_setup_intelligent_dispatches_coordinator(hass, account_id: str, mock_intelligent_data: bool, manual_dispatch_refreshes: bool, planned_dispatches_supported: bool):
249283
async def async_update_intelligent_dispatches_data(is_manual_refresh = False):
250284
"""Fetch data from API endpoint."""

custom_components/octopus_energy/diagnostics.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import logging
55
from typing import Callable
66

7+
from .utils.attributes import dict_to_typed_dict
78
from homeassistant.components.diagnostics import async_redact_data
89
from homeassistant.helpers import entity_registry as er
910
from homeassistant.util.dt import (now)
@@ -135,6 +136,7 @@ async def async_get_diagnostics(client: OctopusEnergyApiClient, account_id: str,
135136
heat_pumps[heat_pump_id] = f"Failed to retrieve - {e}"
136137

137138
return {
139+
"timestamp_captured": now(),
138140
"account": account_info,
139141
"using_cached_account_data": existing_account_info is not None,
140142
"entities": get_entity_info(redacted_mappings),
@@ -143,6 +145,8 @@ async def async_get_diagnostics(client: OctopusEnergyApiClient, account_id: str,
143145
"heat_pumps": heat_pumps,
144146
}
145147

148+
ignored_attributes = ['mpan', 'mprn', 'serial_number', 'friendly_name', 'icon', 'unit_of_measurement', 'device_class', 'state_class', 'account_id']
149+
146150
async def async_get_device_diagnostics(hass, entry, device):
147151
"""Return diagnostics for a device."""
148152

@@ -177,6 +181,7 @@ def get_entity_info(redacted_mappings):
177181

178182
entity_info[unique_id] = {
179183
"state": state.state if state is not None else None,
184+
"attributes": dict_to_typed_dict(state.attributes, ignored_attributes) if state is not None else None,
180185
"last_updated": state.last_updated if state is not None else None,
181186
"last_changed": state.last_changed if state is not None else None
182187
}

custom_components/octopus_energy/intelligent/dispatching.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ def _handle_coordinator_update(self) -> None:
8888
result: IntelligentDispatchesCoordinatorResult = self.coordinator.data if self.coordinator is not None else None
8989
rates = self._rates_coordinator.data.rates if self._rates_coordinator is not None and self._rates_coordinator.data is not None else None
9090

91+
# Skip if no rates are available otherwise our sensor can go off after a restart when it should be restored as one
92+
if rates is None:
93+
return
94+
9195
current_date = utcnow()
9296

9397
self.__init_attributes__(

tests/unit/coordinators/test_async_refresh_intelligent_dispatches.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,64 @@ async def async_save_dispatches(*args, **kwargs):
283283
assert save_dispatches_account_id == None
284284
assert save_dispatches_dispatches == None
285285

286+
@pytest.mark.asyncio
287+
async def test_when_existing_dispatches_returned_and_planned_dispatch_started_and_refreshed_recently_then_started_dispatches_updated():
288+
current = datetime.strptime("2023-07-14T10:30:01+01:00", "%Y-%m-%dT%H:%M:%S%z")
289+
expected_dispatches = IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], [])
290+
mock_api_called = False
291+
async def async_mock_get_intelligent_dispatches(*args, **kwargs):
292+
nonlocal mock_api_called
293+
mock_api_called = True
294+
return expected_dispatches
295+
296+
save_dispatches_called = False
297+
save_dispatches_account_id = None
298+
save_dispatches_dispatches = None
299+
async def async_save_dispatches(*args, **kwargs):
300+
nonlocal save_dispatches_called, save_dispatches_account_id, save_dispatches_dispatches
301+
save_dispatches_called = True
302+
account_id, dispatches = args
303+
save_dispatches_account_id = account_id
304+
save_dispatches_dispatches = dispatches
305+
306+
account_info = get_account_info()
307+
existing_dispatches = IntelligentDispatchesCoordinatorResult(current - timedelta(minutes=2), 1, mock_intelligent_dispatches(), 1, last_retrieved)
308+
existing_dispatches.dispatches.current_state = "SMART_CONTROL_IN_PROGRESS"
309+
existing_dispatches.dispatches.planned = [
310+
IntelligentDispatchItem(
311+
current - timedelta(minutes=1),
312+
current + timedelta(minutes=45),
313+
1.1,
314+
None,
315+
"home"
316+
)
317+
]
318+
319+
with mock.patch.multiple(OctopusEnergyApiClient, async_get_intelligent_dispatches=async_mock_get_intelligent_dispatches):
320+
client = OctopusEnergyApiClient("NOT_REAL")
321+
retrieved_dispatches: IntelligentDispatchesCoordinatorResult = await async_refresh_intelligent_dispatches(
322+
current,
323+
client,
324+
account_info,
325+
intelligent_device,
326+
existing_dispatches,
327+
False,
328+
False,
329+
True,
330+
async_save_dispatches
331+
)
332+
333+
assert mock_api_called == False
334+
assert retrieved_dispatches == existing_dispatches
335+
336+
assert len(retrieved_dispatches.dispatches.started) == 1
337+
assert retrieved_dispatches.dispatches.started[0].start == current.replace(second=0, microsecond=0)
338+
assert retrieved_dispatches.dispatches.started[0].end == current.replace(second=0, microsecond=0) + timedelta(minutes=30)
339+
340+
assert save_dispatches_called == False
341+
assert save_dispatches_account_id == None
342+
assert save_dispatches_dispatches == None
343+
286344
@pytest.mark.asyncio
287345
@pytest.mark.parametrize("existing_dispatches",[
288346
(None),

0 commit comments

Comments
 (0)