diff --git a/apps/predbat/kraken.py b/apps/predbat/kraken.py index d374845a6..025d52fd0 100644 --- a/apps/predbat/kraken.py +++ b/apps/predbat/kraken.py @@ -1234,7 +1234,7 @@ async def async_fetch_dispatches(self): new_completed = self._normalize_dispatches(data.get("completedDispatches"), completed=True) device["completed_dispatches"] = self._merge_completed_dispatches(device.get("completed_dispatches", []), new_completed) - def _publish_dispatch_sensors(self): + async def _publish_dispatch_sensors(self): """Publish an intelligent_dispatch binary_sensor per device and wire octopus_intelligent_slot. Sensor format matches OctopusAPI so predbat's fetch consumes it identically: state on/off if a @@ -1245,6 +1245,7 @@ def _publish_dispatch_sensors(self): if not self.intelligent_devices: return now = datetime.now(timezone.utc) + should_fetch_rates = False slot_list = [] for device_id, device in self.intelligent_devices.items(): index_suffix = self._device_index_suffix(device_id) @@ -1258,6 +1259,7 @@ def _publish_dispatch_sensors(self): end = self._parse_dispatch_dt(dispatch.get("end")) if start and end and start <= now < end: active = True + should_fetch_rates = True break attributes = {"friendly_name": "Kraken Intelligent Dispatches", "icon": "mdi:flash", **device} @@ -1271,6 +1273,10 @@ def _publish_dispatch_sensors(self): if self.get_arg("num_cars", 0) < len(slot_list): self.set_arg("num_cars", len(slot_list)) + if should_fetch_rates: + # dispatch is active now, fetch and publish rates + await self._fetch_and_publish_rates() + async def run(self, seconds, first): """Component run method — called by ComponentBase.start() every 60s. @@ -1318,24 +1324,7 @@ async def run(self, seconds, first): # Fetch import rates + standing charges + export rates when stale if self.current_tariff and rates_due: - rates = await self.async_fetch_rates() - if rates: - had_success = True - self.import_rates = rates - self.rates_fetched_at = datetime.now() - - standing_charge = await self.async_fetch_standing_charges() - if standing_charge is not None: - had_success = True - self.import_standing_charge = standing_charge - - # Fetch export rates if export tariff is known - if self.export_tariff: - export_rates = await self.async_fetch_rates(tariff=self.export_tariff) - if export_rates: - had_success = True - self.export_rates = export_rates - self.export_rates_available = True + had_success = await self._fetch_and_publish_rates() or had_success # Publish rate sensors from current in-memory data (cache or fresh) if self.current_tariff and (first or rates_due): @@ -1351,7 +1340,7 @@ async def run(self, seconds, first): self.dispatch_fetched_at = datetime.now() had_success = True if self.intelligent_devices and (first or dispatch_due): - self._publish_dispatch_sensors() + await self._publish_dispatch_sensors() # Wire import into fetch.py once tariff is discovered (retries until successful) if not self.wired and self.current_tariff: @@ -1400,6 +1389,28 @@ async def run(self, seconds, first): return True + async def _fetch_and_publish_rates(self): + had_success = False + rates = await self.async_fetch_rates() + if rates: + had_success = True + self.import_rates = rates + self.rates_fetched_at = datetime.now() + + standing_charge = await self.async_fetch_standing_charges() + if standing_charge is not None: + had_success = True + self.import_standing_charge = standing_charge + + # Fetch export rates if export tariff is known + if self.export_tariff: + export_rates = await self.async_fetch_rates(tariff=self.export_tariff) + if export_rates: + had_success = True + self.export_rates = export_rates + self.export_rates_available = True + return had_success + class KrakenMockBase: # pragma: no cover """Minimal mock base object so KrakenAPI can be driven from the command line. diff --git a/apps/predbat/tests/test_kraken.py b/apps/predbat/tests/test_kraken.py index d710ec919..4113b41bf 100644 --- a/apps/predbat/tests/test_kraken.py +++ b/apps/predbat/tests/test_kraken.py @@ -1692,7 +1692,7 @@ def test_fetch_dispatches_populates_device(): def test_publish_dispatch_sensors_active_state_and_wiring(): - """_publish_dispatch_sensors publishes an on/off binary_sensor and wires slot + num_cars.""" + """_publish_dispatch_sensors publishes an on binary_sensor and wires slot + num_cars. and fetches rates if in an active dispatch.""" from datetime import datetime, timezone, timedelta api = make_kraken_api() @@ -1704,8 +1704,9 @@ def test_publish_dispatch_sensors_active_state_and_wiring(): captured = {} api.set_arg = MagicMock(side_effect=lambda k, v: captured.__setitem__(k, v)) api.get_arg = MagicMock(return_value=0) # num_cars currently 0 + api.async_fetch_rates = AsyncMock(return_value=[{"value_inc_vat": 7.00}]) - api._publish_dispatch_sensors() + asyncio.run(api._publish_dispatch_sensors()) entity = api.get_entity_name("binary_sensor", "intelligent_dispatch_12345") call = [c for c in api.dashboard_item.call_args_list if c.args[0] == entity][0] @@ -1714,6 +1715,30 @@ def test_publish_dispatch_sensors_active_state_and_wiring(): assert captured["octopus_intelligent_slot"] == [entity] assert captured["num_cars"] == 1 + api.async_fetch_rates.assert_called() + + +def test_publish_dispatch_sensors_non_active_state(): + """_publish_dispatch_sensors publishes an off binary_sensor and doesn't fetches rates.""" + from datetime import datetime, timezone, timedelta + + api = make_kraken_api() + now = datetime.now(timezone.utc) + start = (now + timedelta(minutes=10)).strftime("%Y-%m-%dT%H:%M:%SZ") + end = (now + timedelta(minutes=20)).strftime("%Y-%m-%dT%H:%M:%SZ") + api.intelligent_devices = {"aaa-bbb-12345": {"device_id": "aaa-bbb-12345", "planned_dispatches": [{"start": start, "end": end, "charge_in_kwh": 5.0}], "completed_dispatches": []}} + api.dashboard_item = MagicMock() + api.get_arg = MagicMock(return_value=0) # num_cars currently 0 + api.async_fetch_rates = AsyncMock(return_value=[{"value_inc_vat": 24.5}]) + + asyncio.run(api._publish_dispatch_sensors()) + + entity = api.get_entity_name("binary_sensor", "intelligent_dispatch_12345") + call = [c for c in api.dashboard_item.call_args_list if c.args[0] == entity][0] + assert call.args[1] == "off", "a dispatch spanning now should make the sensor inactive" + + api.async_fetch_rates.assert_not_called() + def test_run_fetches_and_wires_dispatches(): """On a dispatch-due cycle, run() fetches dispatches and wires octopus_intelligent_slot."""