Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
93 changes: 73 additions & 20 deletions apps/predbat/inverter.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,9 @@ def __init__(self, base, id=0, quiet=False):
self.track_discharge_end = "00:00:00"
self.idle_start_minutes = 0
self.idle_end_minutes = 0
# Last export schedule (start, end, enabled) Predbat actually committed to this inverter.
# None means nothing has been committed yet in this run, so the next call commits once.
self.last_export_schedule_committed = None

self.inverter_type = self.base.get_arg("inverter_type", "GE", indirect=False, index=self.id)
# An assumed type is not a chosen one. The user-facing "source returned no data" warnings
Expand Down Expand Up @@ -2073,7 +2076,7 @@ def adjust_battery_target(self, soc, isCharging=False, isExporting=False):

# For inverters that need a button press to apply changes (e.g., Fox), press the button now
if self.inv_time_button_press:
self.press_and_poll_button()
self.press_and_poll_button(side="charge")

if self.base.set_inverter_notify:
self.base.call_notify(f"{self.base.prefix.capitalize()}: Inverter {self.id} Target SoC has been changed to {soc}% at {self.base.time_now_str()}")
Expand Down Expand Up @@ -2447,7 +2450,7 @@ def adjust_pause_mode(self, pause_charge=False, pause_discharge=False):

self.base.log("Inverter {} set pause mode to {}".format(self.id, new_pause_mode))

def adjust_inverter_mode(self, force_export):
def adjust_inverter_mode(self, force_export, changed_start_end=False):
"""
Adjust inverter mode between force export and Demand mode (ECO)

Expand All @@ -2465,6 +2468,10 @@ def adjust_inverter_mode(self, force_export):

"""
inverter_mode_configured = "inverter_mode" in self.base.args
if changed_start_end:
# XXX: Workaround for GivTCP window state update time to take effort
self.base.log("Sleeping (workaround) as start/end of discharge window was just adjusted")
self.sleep(30)
old_inverter_mode = self.base.get_arg("inverter_mode", index=self.id)

if not self.inv_has_fox_inverter_mode and not self.inv_has_ge_eco_toggle:
Expand Down Expand Up @@ -2628,6 +2635,11 @@ def adjust_force_export(self, force_export, new_start_time=None, new_end_time=No
self.log("Warn: Inverter {} unable read discharge window as neither REST, discharge_start_time or discharge_start_hour are set".format(self.id))
return False

# Whether the caller asked us to manage the export times at all this cycle. execute.py calls
# adjust_force_export(False) with no times whenever nothing is being exported, which is a
# different thing from the GE branch below deliberately clearing them.
times_supplied = (new_start_time is not None) or (new_end_time is not None)

# Start time to correct format
if new_start_time:
new_start_time += timedelta(seconds=self.base.inverter_clock_skew_discharge_start * 60)
Expand Down Expand Up @@ -2762,19 +2774,51 @@ def adjust_force_export(self, force_export, new_start_time=None, new_end_time=No
if not old_discharge_enable:
self.log("Inverter {} Turning on scheduled export".format(self.id))

if (new_end != old_end) or (new_start != old_start) or (force_export != old_discharge_enable) or changed_start_end:
# Whether the export schedule itself actually needs (re)committing to the inverter, as opposed
# to us merely having rewritten the time registers. For H M format those registers are rewritten
# every cycle as a write-reliability workaround (#1529), so changed_start_end is True on every
# cycle of a stable export window and must not be used to gate the commit - on Solis each button
# press zeroes the timed current registers (#4709), and it also triggers the 30s GivTCP sleep in
# adjust_inverter_mode. Tracking what we last committed keeps a stable window quiet while still
# committing once after a restart, when nothing has been committed yet (#4000).
# When the caller supplied no times at all we are not managing the export window this cycle, so
# neither time can have "changed". Comparing None against the time the inverter still reports is
# never equal, which pressed the update button on every idle cycle for the rest of the day (#2328).
# A genuine transition out of export is still caught by force_export != old_discharge_enable below.
start_changed = times_supplied and new_start != old_start
end_changed = times_supplied and new_end != old_end
export_schedule = (new_start, new_end, force_export)
# Separately, whether the start/end times themselves actually moved - used below to gate the
# GivTCP settle sleep, which exists for the window write specifically ("start/end of discharge
# window was just adjusted"). schedule_changed alone is too broad for that: it also goes True on
# a bare scheduled_discharge_enable flip with the window untouched, which doesn't need settling.
times_changed = start_changed or end_changed
schedule_changed = times_changed or (force_export != old_discharge_enable)
if is_hm_format and export_schedule != self.last_export_schedule_committed:
# Only the H M path rewrites unconditionally, so only it needs the extra commit-once-per-run
# safety net; every other format already commits on a real change alone. Treat it as a real
# window write too, so the settle sleep still runs on this first post-restart commit.
schedule_changed = True
times_changed = True

if schedule_changed:
committed = True
if self.inv_time_button_press:
self.press_and_poll_button()
committed = self.press_and_poll_button(side="discharge")
Comment thread
chalfontchubby marked this conversation as resolved.
Outdated
if committed:
# Only remember a commit that actually succeeded, otherwise a failed button press would
# be recorded as done and never retried until the schedule next changes on its own.
self.last_export_schedule_committed = export_schedule
Comment thread
chalfontchubby marked this conversation as resolved.

# Force export, turn it on after we change the window
if force_export:
self.adjust_inverter_mode(force_export)
self.adjust_inverter_mode(force_export, changed_start_end=times_changed)
if not self.inv_has_charge_enable_time and (self.inv_output_charge_control == "current"):
if self.inv_charge_control_immediate:
self.enable_charge_discharge_with_time_current("discharge", True)

# Notify
if changed_start_end:
if schedule_changed and changed_start_end:
if self.base.set_inverter_notify:
self.base.call_notify(f"{self.base.prefix.capitalize()}: Inverter {self.id} Export time slot set to {new_start} - {new_end} at time {self.base.time_now_str()}")

Expand Down Expand Up @@ -2828,7 +2872,7 @@ def disable_charge_window(self, notify=True):
else:
# Press button if needed
if self.inv_time_button_press:
self.press_and_poll_button()
self.press_and_poll_button(side="charge")

# Updated cached status to disabled
# Don't do it if notify is not set as this is just a temporary call when setting the charge window
Expand Down Expand Up @@ -3246,14 +3290,22 @@ def adjust_charge_window(self, charge_start_time, charge_end_time, minutes_now):
if (new_start != old_start) or (new_end != old_end) or (old_charge_schedule_enable == "off"):
# For Solis inverters and fox we also have to press the update_charge_discharge button to send the times to the inverter
if self.inv_time_button_press:
self.press_and_poll_button()
self.press_and_poll_button(side="charge")

def press_and_poll_button(self):
def press_and_poll_button(self, side="both"):
"""
Press charge/discharge update button(s) for the inverter.
Priority:
1. charge_discharge_update_button
2. charge_update_button and discharge_update_button

`side` is one of "charge", "discharge" or "both" - the side that actually changed and
needs its update pressed. A single combined button (schedule_write_button or
charge_discharge_update_button) always covers both sides regardless, so `side` only
matters when separate charge_update_button/discharge_update_button entities are
configured (e.g. some Solis setups) - pressing the unrelated side's button there writes
an unnecessary command to the inverter (and, on hardware that counts these towards flash/
EEPROM wear, batpred#2328).
"""
success = True

Expand All @@ -3266,17 +3318,18 @@ def press_and_poll_button(self):
elif entity_id_charge_discharge_updated_button:
success = self._press_single_button_and_poll(entity_id_charge_discharge_updated_button)
else:
# Try separate charge and discharge buttons
charge_button = self.base.get_arg("charge_update_button", indirect=False, index=self.id)
discharge_button = self.base.get_arg("discharge_update_button", indirect=False, index=self.id)

if charge_button:
if not self._press_single_button_and_poll(charge_button):
success = False

if discharge_button:
if not self._press_single_button_and_poll(discharge_button):
success = False
# Try separate charge and discharge buttons - only press the one(s) relevant to `side`
if side in ("charge", "both"):
charge_button = self.base.get_arg("charge_update_button", indirect=False, index=self.id)
if charge_button:
if not self._press_single_button_and_poll(charge_button):
success = False

if side in ("discharge", "both"):
discharge_button = self.base.get_arg("discharge_update_button", indirect=False, index=self.id)
if discharge_button:
if not self._press_single_button_and_poll(discharge_button):
success = False

return success

Expand Down
Loading
Loading