Skip to content
Merged
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
65 changes: 55 additions & 10 deletions apps/predbat/octopus.py
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,11 @@ def initialize(self, key, account_id, automatic):
# run() compares this against the live set so a device appearing, disappearing or being
# suspended re-wires the slots without waiting for a restart (issue #4648).
self.intelligent_config_devices = None
# Who held the car slots when automatic_config() last ran, so run() re-wires them when that changes
self.intelligent_config_owner = None
# The slot entities automatic_config() last wired the car slots to, so it only ever clears wiring
# that is still its own - None until it has wired any
self.intelligent_config_slots = None
self.tariff_fetched_at = None
self.device_fetched_at = None
self.sensor_updated_at = None
Expand Down Expand Up @@ -766,6 +771,19 @@ async def run(self, seconds, first):
self.log("OctopusAPI: Live intelligent devices changed from {} to {}, reconfiguring car slots".format(self.intelligent_config_devices, active_devices))
self.automatic_config(self.tariffs)
self.refresh_discovery()
elif sensor_due and getattr(self.base, "car_slot_owner", None) != self.intelligent_config_owner:
slot_owner = getattr(self.base, "car_slot_owner", None)
if self.car_slots_released_to_us(slot_owner, active_devices):
# Another component has given the car slots up - the Ohme component does once it
# finds Octopus Intelligent is driving the car and not the charger (#5402). The
# device set has not moved, so nothing else would wire them
self.log("OctopusAPI: Car slots released by the {} component, reconfiguring car slots".format(self.intelligent_config_owner))
self.automatic_config(self.tariffs)
self.refresh_discovery()
else:
# Taken by another component, or given up with nothing here to wire them to: note
# the owner and leave the wiring to whoever has it
self.intelligent_config_owner = slot_owner

# Unconditional and outside the "if self.automatic:" block above (unlike the two calls
# inside it, which exist only to refresh the report in the SAME cycle a wiring change
Expand Down Expand Up @@ -1156,6 +1174,22 @@ async def async_join_saving_session_events(self, account_id, event_code):
# Re-fetch the saving sessions if we have joined any
self.saving_sessions = await self.async_get_saving_sessions(account_id)

def car_slots_released_to_us(self, slot_owner, active_devices):
"""
Has another component given up the car slots for this one to wire to its own dispatches.

Only when there is something to wire them to: a live, non-suspended Intelligent device on a
tariff that is still Intelligent. A component also lets go when there are no dispatches to
be had from anyone - every device suspended, or the tariff no longer Intelligent, where
self.intelligent_devices is not refreshed and still holds the old device - and has then
wired the slots to something of its own, which must not be overwritten or cleared (#5405 review).
"""
if slot_owner and slot_owner != "octopus":
return False
if not active_devices:
return False
return self.is_intelligent_go_tariff(self.tariffs.get("import", {}).get("tariffCode"))

def get_active_intelligent_device_ids(self):
"""
Return the sorted list of live intelligent device IDs that are not suspended.
Expand Down Expand Up @@ -1446,20 +1480,31 @@ def automatic_config(self, tariffs):
slot_list.append(self.get_entity_name("binary_sensor", "intelligent_dispatch", index=index_suffix))
ready_list.append(self.get_entity_name("select", "intelligent_target_time", index=index_suffix))
limit_list.append(self.get_entity_name("number", "intelligent_target_soc", index=index_suffix))
self.set_arg("octopus_intelligent_slot", slot_list)
self.set_arg("octopus_ready_time", ready_list)
self.set_arg("octopus_charge_limit", limit_list)
# Increase number of cars if we have more active devices than the current limit to ensure all devices can be configured
num_cars = self.get_arg("num_cars", 0)
if num_cars < len(active_devices):
self.set_arg("num_cars", len(active_devices))
if active_devices:
self.log("OctopusAPI: Car slots wired to intelligent devices {}".format(sorted(active_devices)))
# With no active device left the wiring is cleared - but only wiring that is still what
# was put there from here. The slots are not claimed while nothing owns them, so another
# component may have wired them to something of its own since: the Ohme component does,
# to the charger's own schedule, when the device it was following is suspended
current_slots = self.get_arg("octopus_intelligent_slot", default=None, indirect=False)
if not active_devices and (not self.intelligent_config_slots or current_slots != self.intelligent_config_slots):
self.log("OctopusAPI: No active intelligent devices, and the car slot wiring is not from here - leaving it alone")
else:
self.log("OctopusAPI: No active intelligent devices remain, cleared the car slot wiring")
self.set_arg("octopus_intelligent_slot", slot_list)
self.set_arg("octopus_ready_time", ready_list)
self.set_arg("octopus_charge_limit", limit_list)
self.intelligent_config_slots = slot_list
# Increase number of cars if we have more active devices than the current limit to ensure all devices can be configured
num_cars = self.get_arg("num_cars", 0)
if num_cars < len(active_devices):
self.set_arg("num_cars", len(active_devices))
if active_devices:
self.log("OctopusAPI: Car slots wired to intelligent devices {}".format(sorted(active_devices)))
else:
self.log("OctopusAPI: No active intelligent devices remain, cleared the car slot wiring")

# Record the device set this wiring was built for so run() can spot it changing later
self.intelligent_config_devices = self.get_active_intelligent_device_ids()
# And who held the car slots, so run() can wire them once another component lets go of them
self.intelligent_config_owner = slot_owner

def _current_standing_charge_p(self, direction):
"""
Expand Down
62 changes: 55 additions & 7 deletions apps/predbat/ohme.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,9 @@ def initialize(self, email, password, ohme_automatic=False, ohme_automatic_octop
self.control_active = False
# Whether the car is on Octopus Intelligent as last decided, None until the first decision
self.octopus_intelligent = None
# Octopus Intelligent is driving a device that is not this charger - the car itself, say - so
# Octopus schedules the charge and its own dispatches are the plan, see octopus_intelligent_wanted()
self.octopus_other_device = False
# How the car slot args are wired to the session slots, None when they are not - see update_slot_mode()
self.slot_mode = None
# The mode the args were last wired for. Kept apart from slot_mode so that a wiring change
Expand Down Expand Up @@ -338,7 +341,7 @@ def enable_control(self, octopus_intelligent):
self.log("Warn: Ohme API: ohme_control needs ohme_automatic set to register the car, charge control is disabled")
return
if octopus_intelligent:
self.log("Warn: Ohme API: ohme_control is ignored while the Intelligent slots come from Ohme - Octopus already schedules the charge")
self.log("Warn: Ohme API: ohme_control is ignored on Octopus Intelligent - Octopus already schedules the charge")
return
self.control_active = True
self.log("Info: Ohme API: Predbat-led charge control enabled")
Expand Down Expand Up @@ -497,10 +500,19 @@ def octopus_intelligent_wanted(self):
control. Left unset it is auto-detected, but only when ohme_automatic is on - otherwise
enabling this would start rewiring the config of every existing Ohme user who has asked
Predbat for nothing.

Detection needs more than an Intelligent tariff: it is the device Octopus Intelligent drives
that says whose slots are the dispatches (#5402). Where that is the car itself - a BMW or a
Volkswagen linked to Octopus directly - Octopus schedules the charge through the car and the
Ohme is only the socket, so Ohme's session has nothing to do with what Octopus bills as
off-peak. The slots are then left to the Octopus component, and octopus_other_device is set
so nothing of Ohme's stands in for them - see charger_slots_wanted() and update_slot_mode().
"""
if self.ohme_automatic_octopus_intelligent is not None:
self.octopus_other_device = False
return bool(self.ohme_automatic_octopus_intelligent)
if not self.ohme_automatic:
self.octopus_other_device = False
return False

# OctopusAPI has already detected this by the time we first run - it sits earlier in
Expand All @@ -512,7 +524,38 @@ def octopus_intelligent_wanted(self):
# fetches. This is asked again on every poll, so an answer already given is kept rather
# than dropping an Intelligent car's cheap rate over a gap in the account data
return bool(self.octopus_intelligent)
return bool(octopus.is_intelligent_go_tariff(tariff_code))
if not octopus.is_intelligent_go_tariff(tariff_code):
self.octopus_other_device = False
return False

# The devices Octopus Intelligent is live on. OctopusAPI keeps the last known set through a
# failed poll, and holds only LIVE devices, so this does not flap with the API. A suspended
# device is one the customer has turned smart charging off for: Octopus is not scheduling it,
# which is also why OctopusAPI.automatic_config() leaves it out of its own car slot wiring
devices = list((octopus.get_intelligent_devices() or {}).values())
active = [device for device in devices if not device.get("suspended")]
self.octopus_other_device = False
if any(self.is_ohme_charger(device) for device in active):
return True
if active:
# Octopus is driving something else, and wires the car slots to its dispatches itself
self.octopus_other_device = True
return False
if devices:
# Every device is suspended, so Octopus is scheduling nothing and there are no dispatches
# to take from anyone. Whatever charging happens is on the charger's own schedule
return False
# An Intelligent tariff with no Intelligent device at all. Octopus has nothing to wire the car
# slots to, so Ohme's slots are the only record of the charge there is: keep taking them, as
# was done before the device was looked at
return True

@staticmethod
def is_ohme_charger(device):
"""
Is this Octopus Intelligent device an Ohme charger, going by what Octopus says it is.
"""
return bool(device.get("is_charger")) and str(device.get("provider") or "").strip().lower() == "ohme"

async def update_slot_mode(self):
"""
Expand All @@ -525,15 +568,19 @@ async def update_slot_mode(self):
Only decides: run() wires the args to match with apply_slot_mode(), once the slots have
been published the new way.
"""
was_other_device = self.octopus_other_device
octopus_intelligent = self.octopus_intelligent_wanted()
if octopus_intelligent != self.octopus_intelligent:
if octopus_intelligent != self.octopus_intelligent or self.octopus_other_device != was_other_device:
if octopus_intelligent:
self.log("Info: Ohme API: Octopus Intelligent is in use, taking the car slots from Ohme")
elif self.octopus_other_device:
self.log("Info: Ohme API: Octopus Intelligent is driving another device, not this charger - leaving the car slots to Octopus")
elif self.octopus_intelligent is not None:
self.log("Info: Ohme API: Octopus Intelligent is no longer in use, the Ohme slots no longer carry the Intelligent rate")
self.octopus_intelligent = octopus_intelligent
# Predbat-led control is ruled out by Intelligent, so it follows the same change
self.enable_control(octopus_intelligent)
# Predbat-led control is ruled out wherever Octopus schedules the charge, whichever device
# it does that through, so it follows the same change
self.enable_control(octopus_intelligent or self.octopus_other_device)

# Predbat has stood down but is still holding the charger: hand it back. Checked on every
# poll rather than only on the change, as release_charger() clears control_charging last -
Expand Down Expand Up @@ -576,9 +623,10 @@ def charger_slots_wanted(self, octopus_intelligent):
used as the plan instead, as car load only - they earn no cheap rate on any other tariff.

Left alone when the car slots are already wired to something else, whether in apps.yaml or
by the Octopus component for its own Intelligent devices.
by the Octopus component for its own Intelligent devices. Never used while Octopus
Intelligent drives another device: Octopus schedules that charge, not Ohme.
"""
if not self.ohme_automatic or octopus_intelligent or self.control_active:
if not self.ohme_automatic or octopus_intelligent or self.octopus_other_device or self.control_active:
self.charger_slots_blocked = None
return False
existing = self.get_arg("octopus_intelligent_slot", default=None, indirect=False)
Expand Down
Loading
Loading