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
56 changes: 48 additions & 8 deletions apps/predbat/inverter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1771,6 +1771,10 @@ def write_and_poll_option(self, name, entity_id, new_value, ignore_fail=False):

old_value = self.base.get_state_wrapper(entity_id, refresh=True)

# Ensure new_value is a string for string operations (e.g. when an integer hour/minute is passed for a time entity)
if not isinstance(new_value, str):
new_value = str(new_value)

# If time format of the selector is %H:%M and we pass in %H:%M:%S then we need to strip the seconds
if old_value and (":" in old_value) and (":" in new_value) and (len(old_value) == 5) and (len(new_value) == 8):
new_value = new_value[:5]
Expand Down Expand Up @@ -2147,8 +2151,17 @@ def adjust_force_export(self, force_export, new_start_time=None, new_end_time=No

if self.inv_charge_time_format == "H M":
# If the inverter uses hours and minutes then write to these entities too
self.write_and_poll_option("discharge_start_hour", self.base.get_arg("discharge_start_hour", indirect=False, index=self.id), int(new_start[:2]))
self.write_and_poll_option("discharge_start_minute", self.base.get_arg("discharge_start_minute", indirect=False, index=self.id), int(new_start[3:5]))
# If the entity is a time entity (e.g. for FB00 firmware), write the full time string instead of just the integer component
start_hour_id = self.base.get_arg("discharge_start_hour", indirect=False, index=self.id)
if start_hour_id and isinstance(start_hour_id, str) and start_hour_id.startswith("time."):
self.write_and_poll_option("discharge_start_hour", start_hour_id, new_start)
else:
self.write_and_poll_option("discharge_start_hour", start_hour_id, int(new_start[:2]))
start_minute_id = self.base.get_arg("discharge_start_minute", indirect=False, index=self.id)
if start_minute_id and isinstance(start_minute_id, str) and start_minute_id.startswith("time."):
self.write_and_poll_option("discharge_start_minute", start_minute_id, new_start)
else:
self.write_and_poll_option("discharge_start_minute", start_minute_id, int(new_start[3:5]))
elif self.inv_charge_time_format == "H:M-H:M":
# If the inverter uses hours and minutes then write to these entities too
discharge_time = new_start + "-" + new_end
Expand All @@ -2169,8 +2182,17 @@ def adjust_force_export(self, force_export, new_start_time=None, new_end_time=No

# If the inverter uses hours and minutes then write to these entities too
if self.inv_charge_time_format == "H M":
self.write_and_poll_option("discharge_end_hour", self.base.get_arg("discharge_end_hour", indirect=False, index=self.id), int(new_end[:2]))
self.write_and_poll_option("discharge_end_minute", self.base.get_arg("discharge_end_minute", indirect=False, index=self.id), int(new_end[3:5]))
# If the entity is a time entity (e.g. for FB00 firmware), write the full time string instead of just the integer component
end_hour_id = self.base.get_arg("discharge_end_hour", indirect=False, index=self.id)
if end_hour_id and isinstance(end_hour_id, str) and end_hour_id.startswith("time."):
self.write_and_poll_option("discharge_end_hour", end_hour_id, new_end)
else:
self.write_and_poll_option("discharge_end_hour", end_hour_id, int(new_end[:2]))
end_minute_id = self.base.get_arg("discharge_end_minute", indirect=False, index=self.id)
if end_minute_id and isinstance(end_minute_id, str) and end_minute_id.startswith("time."):
self.write_and_poll_option("discharge_end_minute", end_minute_id, new_end)
else:
self.write_and_poll_option("discharge_end_minute", end_minute_id, int(new_end[3:5]))
elif self.inv_charge_time_format == "H:M-H:M":
pass
else:
Expand Down Expand Up @@ -2587,8 +2609,17 @@ def adjust_charge_window(self, charge_start_time, charge_end_time, minutes_now):

if self.inv_charge_time_format == "H M":
# If the inverter uses hours and minutes then write to these entities too
self.write_and_poll_option("charge_start_hour", self.base.get_arg("charge_start_hour", indirect=False, index=self.id), int(new_start[:2]))
self.write_and_poll_option("charge_start_minute", self.base.get_arg("charge_start_minute", indirect=False, index=self.id), int(new_start[3:5]))
# If the entity is a time entity (e.g. for FB00 firmware), write the full time string instead of just the integer component
start_hour_id = self.base.get_arg("charge_start_hour", indirect=False, index=self.id)
if start_hour_id and isinstance(start_hour_id, str) and start_hour_id.startswith("time."):
self.write_and_poll_option("charge_start_hour", start_hour_id, new_start)
else:
self.write_and_poll_option("charge_start_hour", start_hour_id, int(new_start[:2]))
start_minute_id = self.base.get_arg("charge_start_minute", indirect=False, index=self.id)
if start_minute_id and isinstance(start_minute_id, str) and start_minute_id.startswith("time."):
self.write_and_poll_option("charge_start_minute", start_minute_id, new_start)
else:
self.write_and_poll_option("charge_start_minute", start_minute_id, int(new_start[3:5]))
Comment on lines +2612 to +2622
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new behavior for GS_fb00/time.* entities (writing full "HH:MM:SS" to _hour/_minute when those config args resolve to time.) isn’t covered by existing inverter tests. Please add a unit test case (e.g., in apps/predbat/tests/test_inverter.py) that sets discharge/charge *_hour (and optionally _minute) to a time. entity and verifies adjust_force_export/adjust_charge_window call the time service with the full time string and don’t attempt to write integer components.

Copilot generated this review using guidance from repository custom instructions.
elif self.inv_charge_time_format == "H:M-H:M":
# If the inverter uses hours and minutes then write to these entities too
charge_time = new_start + "-" + new_end
Expand All @@ -2606,8 +2637,17 @@ def adjust_charge_window(self, charge_start_time, charge_end_time, minutes_now):
self.write_and_poll_option("charge_end_time", entity_id_end, new_end)

if self.inv_charge_time_format == "H M":
self.write_and_poll_option("charge_end_hour", self.base.get_arg("charge_end_hour", indirect=False, index=self.id), int(new_end[:2]))
self.write_and_poll_option("charge_end_minute", self.base.get_arg("charge_end_minute", indirect=False, index=self.id), int(new_end[3:5]))
# If the entity is a time entity (e.g. for FB00 firmware), write the full time string instead of just the integer component
end_hour_id = self.base.get_arg("charge_end_hour", indirect=False, index=self.id)
if end_hour_id and isinstance(end_hour_id, str) and end_hour_id.startswith("time."):
self.write_and_poll_option("charge_end_hour", end_hour_id, new_end)
else:
self.write_and_poll_option("charge_end_hour", end_hour_id, int(new_end[:2]))
end_minute_id = self.base.get_arg("charge_end_minute", indirect=False, index=self.id)
if end_minute_id and isinstance(end_minute_id, str) and end_minute_id.startswith("time."):
self.write_and_poll_option("charge_end_minute", end_minute_id, new_end)
else:
self.write_and_poll_option("charge_end_minute", end_minute_id, int(new_end[3:5]))
elif self.inv_charge_time_format == "H:M-H:M":
pass
else:
Expand Down
6 changes: 6 additions & 0 deletions apps/predbat/tests/test_infra.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,12 @@ def call_service(self, service, **kwargs):
print("Warn: Service for entity {} not a select".format(entity_id))
elif entity_id in self.dummy_items:
self.dummy_items[entity_id] = kwargs.get("option", None)
elif service == "time/set_value":
entity_id = kwargs.get("entity_id", None)
if not entity_id.startswith("time."):
print("Warn: Service for entity {} not a time".format(entity_id))
elif entity_id in self.dummy_items:
self.dummy_items[entity_id] = kwargs.get("time", None)
return None

def set_state(self, entity_id, state, attributes=None):
Expand Down
102 changes: 102 additions & 0 deletions apps/predbat/tests/test_inverter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1393,6 +1393,94 @@ def test_discharge_window_none_value(test_name, my_predbat, dummy_items):
return failed


def test_time_entity_hour_write(test_name, ha, inv, dummy_rest, direction, new_start, new_end):
"""
Test that when *_start_hour / *_end_hour args resolve to time.* entities the full
time string is written (not just the integer hour component) and that no TypeError
is raised.

direction: "discharge" or "charge"
"""
failed = False
print("Test: {} direction={}".format(test_name, direction))

inv.rest_data = None
inv.inv_charge_time_format = "H M"

new_start_ts = datetime.strptime(new_start, "%H:%M:%S")
new_end_ts = datetime.strptime(new_end, "%H:%M:%S")

if direction == "discharge":
# Wire up time entities for discharge slot
ha.dummy_items["select.discharge_start_time"] = "00:00:00"
ha.dummy_items["select.discharge_end_time"] = "00:00:00"
ha.dummy_items["time.discharge_start_hour"] = "00:00:00"
ha.dummy_items["time.discharge_end_hour"] = "00:00:00"
ha.dummy_items["sensor.predbat_GE_0_scheduled_discharge_enable"] = "off"
ha.dummy_items["number.discharge_target_soc"] = inv.reserve_percent
ha.dummy_items["select.inverter_mode"] = "Eco"
ha.dummy_items["switch.inverter_button"] = "off"

inv.base.args["discharge_start_time"] = "select.discharge_start_time"
inv.base.args["discharge_end_time"] = "select.discharge_end_time"
inv.base.args["discharge_start_hour"] = "time.discharge_start_hour"
inv.base.args["discharge_end_hour"] = "time.discharge_end_hour"
# No discharge_start_minute / discharge_end_minute – those args are absent

try:
inv.adjust_force_export(True, new_start_ts, new_end_ts)
except TypeError as e:
print("ERROR: TypeError raised: {}".format(e))
return True

if ha.dummy_items.get("time.discharge_start_hour") != new_start:
print("ERROR: discharge_start_hour time entity should be {} got {}".format(new_start, ha.dummy_items.get("time.discharge_start_hour")))
failed = True
if ha.dummy_items.get("time.discharge_end_hour") != new_end:
print("ERROR: discharge_end_hour time entity should be {} got {}".format(new_end, ha.dummy_items.get("time.discharge_end_hour")))
failed = True
if ha.dummy_items.get("select.discharge_start_time") != new_start:
print("ERROR: discharge_start_time select entity should be {} got {}".format(new_start, ha.dummy_items.get("select.discharge_start_time")))
failed = True
if ha.dummy_items.get("select.discharge_end_time") != new_end:
print("ERROR: discharge_end_time select entity should be {} got {}".format(new_end, ha.dummy_items.get("select.discharge_end_time")))
failed = True

else: # charge
ha.dummy_items["select.charge_start_time"] = "00:00:00"
ha.dummy_items["select.charge_end_time"] = "00:00:00"
ha.dummy_items["time.charge_start_hour"] = "00:00:00"
ha.dummy_items["time.charge_end_hour"] = "00:00:00"
ha.dummy_items["switch.scheduled_charge_enable"] = "off"
ha.dummy_items["switch.inverter_button"] = "off"

inv.base.args["charge_start_time"] = "select.charge_start_time"
inv.base.args["charge_end_time"] = "select.charge_end_time"
inv.base.args["charge_start_hour"] = "time.charge_start_hour"
inv.base.args["charge_end_hour"] = "time.charge_end_hour"

try:
inv.adjust_charge_window(new_start_ts, new_end_ts, inv.base.minutes_now)
except TypeError as e:
print("ERROR: TypeError raised: {}".format(e))
return True

if ha.dummy_items.get("time.charge_start_hour") != new_start:
print("ERROR: charge_start_hour time entity should be {} got {}".format(new_start, ha.dummy_items.get("time.charge_start_hour")))
failed = True
if ha.dummy_items.get("time.charge_end_hour") != new_end:
print("ERROR: charge_end_hour time entity should be {} got {}".format(new_end, ha.dummy_items.get("time.charge_end_hour")))
failed = True
if ha.dummy_items.get("select.charge_start_time") != new_start:
print("ERROR: charge_start_time select entity should be {} got {}".format(new_start, ha.dummy_items.get("select.charge_start_time")))
failed = True
if ha.dummy_items.get("select.charge_end_time") != new_end:
print("ERROR: charge_end_time select entity should be {} got {}".format(new_end, ha.dummy_items.get("select.charge_end_time")))
failed = True

return failed


def run_inverter_tests(my_predbat_dummy):
"""
Test the inverter functions
Expand Down Expand Up @@ -2084,5 +2172,19 @@ def run_inverter_tests(my_predbat_dummy):
if failed:
return failed

# Tests for time.* entities used for discharge/charge hour config (GS_fb00 firmware)
failed |= test_time_entity_hour_write("time_entity_discharge_hour1", ha, inv, dummy_rest, "discharge", "22:30:00", "23:59:00")
if failed:
return failed
failed |= test_time_entity_hour_write("time_entity_discharge_hour2", ha, inv, dummy_rest, "discharge", "00:00:00", "06:30:00")
if failed:
return failed
failed |= test_time_entity_hour_write("time_entity_charge_hour1", ha, inv, dummy_rest, "charge", "01:00:00", "05:30:00")
if failed:
return failed
failed |= test_time_entity_hour_write("time_entity_charge_hour2", ha, inv, dummy_rest, "charge", "23:00:00", "23:59:00")
if failed:
return failed

failed |= test_inverter_self_test("self_test1", my_predbat)
return failed
Loading