Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 31 additions & 20 deletions apps/predbat/kraken.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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}
Expand All @@ -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.

Expand Down Expand Up @@ -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):
Expand All @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
29 changes: 27 additions & 2 deletions apps/predbat/tests/test_kraken.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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]
Expand All @@ -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."""
Expand Down