From 15337ea2fee0336756a3f9d340ec4a0c18f0a389 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Wed, 19 Aug 2026 07:58:26 +0100 Subject: [PATCH 1/2] fix: read saving session available_events from configured entity, not rewritten one The binary_sensor -> event entity rewrite used as a fallback for legacy configs rebound entity_id unconditionally whenever joined_events was empty, so the following available_events read used the rewritten name instead of the configured entity. If the rewritten entity didn't exist (e.g. custom entity names with no '_sessions' substring), available_events came back empty and saving-session auto-join silently stopped working with nothing logged. Fixes #4573 --- apps/predbat/octopus.py | 14 +++-- apps/predbat/tests/test_saving_session.py | 70 +++++++++++++++++++++++ apps/predbat/unit_test.py | 11 +++- 3 files changed, 90 insertions(+), 5 deletions(-) diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index 2e4951711..ef0c5a927 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -2940,11 +2940,17 @@ def fetch_octopus_sessions(self, axle_sessions=None): if entity_id: state = self.get_arg("octopus_saving_session", False) joined_events = self.get_state_wrapper(entity_id=entity_id, attribute="joined_events") - if not joined_events: - entity_id = entity_id.replace("binary_sensor.", "event.").replace("_sessions", "_session_events") - joined_events = self.get_state_wrapper(entity_id=entity_id, attribute="joined_events") - available_events = self.get_state_wrapper(entity_id=entity_id, attribute="available_events") + if not joined_events and not available_events: + # Legacy binary_sensor entities carry neither attribute - fall back to the + # newer event entity naming convention, but only adopt it if it actually has data + fallback_entity_id = entity_id.replace("binary_sensor.", "event.").replace("_sessions", "_session_events") + fallback_joined_events = self.get_state_wrapper(entity_id=fallback_entity_id, attribute="joined_events") + fallback_available_events = self.get_state_wrapper(entity_id=fallback_entity_id, attribute="available_events") + if fallback_joined_events or fallback_available_events: + entity_id = fallback_entity_id + joined_events = fallback_joined_events + available_events = fallback_available_events if available_events and not self.get_arg("octopus_saving_auto_join", True): self.log("Octopus: Saving session auto-join is disabled, not joining available events") diff --git a/apps/predbat/tests/test_saving_session.py b/apps/predbat/tests/test_saving_session.py index 6126151d3..19a270b3d 100644 --- a/apps/predbat/tests/test_saving_session.py +++ b/apps/predbat/tests/test_saving_session.py @@ -592,6 +592,76 @@ def setup_items(): return failed +def test_saving_session_custom_entity_no_rewrite_match(my_predbat): + """ + Test that available_events is read from the configured entity even when its name + does not match the binary_sensor -> event rewrite pattern (no '_sessions' substring), + and no rewritten entity exists at all. + Covers GitHub issue #4573 + """ + print("Test saving session with custom entity name that does not match the rewrite pattern (issue #4573)") + ha = my_predbat.ha_interface + failed = False + date_today = datetime.now().strftime("%Y-%m-%d") + tz_offset = int(my_predbat.midnight_utc.tzinfo.utcoffset(my_predbat.midnight_utc).total_seconds() / 3600) + tz_offset = f"{tz_offset:02d}" + + # Custom entity name bridging a saving session event. It has no '_sessions' substring + # so the legacy binary_sensor -> event rewrite would point at a non-existent entity if + # it were ever applied. joined_events is empty (nothing joined yet) but available_events + # is populated - this is exactly the state auto-join needs to act on. + session_binary = f""" +state: off +available_events: + - id: 9999 + start: '{date_today}T18:00:00+{tz_offset}:00' + end: '{date_today}T19:00:00+{tz_offset}:00' + duration_in_minutes: 60 + rewarded_octopoints: null + octopoints_per_kwh: 505 + code: EVENT_TEST +joined_events: [] +friendly_name: Predbat Octopus Power Down For Predbat +""" + + ha.dummy_items.clear() + ha.dummy_items["binary_sensor.predbat_octopus_power_down_for_predbat"] = yaml.safe_load(session_binary) + ha.dummy_items["sensor.octopus_free_session"] = {} + my_predbat.args["octopus_saving_session"] = "binary_sensor.predbat_octopus_power_down_for_predbat" + my_predbat.args["octopus_free_session"] = "sensor.octopus_free_session" + if "octopus_free_url" in my_predbat.args: + del my_predbat.args["octopus_free_url"] + if "octopus_saving_session_join" in my_predbat.args: + del my_predbat.args["octopus_saving_session_join"] + my_predbat.args["octopus_saving_session_octopoints_per_penny"] = 10 + # Reset throttle so a join is attempted + my_predbat.octopus_last_joined_try = None + + ha.service_store_enable = True + ha.service_store = [] + my_predbat.fetch_octopus_sessions() + service_result = ha.get_service_store() + ha.service_store_enable = False + + join_calls = [svc for svc in service_result if "join" in svc[0]] + if len(join_calls) != 1: + print(f"ERROR: Expected 1 join call reading available_events from the configured entity, got {len(join_calls)}: {service_result}") + failed = True + elif join_calls[0][1].get("entity_id") != "binary_sensor.predbat_octopus_power_down_for_predbat": + print(f"ERROR: Expected join call to use the configured entity, got {join_calls[0][1]}") + failed = True + else: + print(" PASS: available_events read from the configured entity despite no rewrite match") + + if not failed: + print("PASS: Custom entity name (no rewrite match) auto-join test passed") + + # Restore default throttle state so we do not leak it to other tests + my_predbat.octopus_last_joined_try = None + + return failed + + def test_saving_session_default_rate(my_predbat): """ Test that saving sessions with no octopoints_per_kwh use the default rate diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index 918471906..ca26428dc 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -64,7 +64,15 @@ from tests.test_solax import run_solax_tests from tests.test_sigenergy import run_sigenergy_tests from tests.test_single_debug import run_single_debug -from tests.test_saving_session import test_saving_session, test_saving_session_null_octopoints, test_saving_session_notify_config, test_saving_session_default_rate, test_saving_session_axle_conflict, test_saving_session_auto_join_toggle +from tests.test_saving_session import ( + test_saving_session, + test_saving_session_null_octopoints, + test_saving_session_notify_config, + test_saving_session_default_rate, + test_saving_session_axle_conflict, + test_saving_session_auto_join_toggle, + test_saving_session_custom_entity_no_rewrite_match, +) from tests.test_secrets import run_secrets_tests from tests.test_ge_cloud import test_ge_cloud from tests.test_teslemetry import test_teslemetry @@ -436,6 +444,7 @@ def main(): ("saving_session_default_rate", test_saving_session_default_rate, "Saving session default rate injection test", False), ("saving_session_axle_conflict", test_saving_session_axle_conflict, "Saving session Axle conflict avoidance test (issue #4120)", False), ("saving_session_auto_join_toggle", test_saving_session_auto_join_toggle, "Saving session auto-join toggle test (issue #4120)", False), + ("saving_session_custom_entity_no_rewrite_match", test_saving_session_custom_entity_no_rewrite_match, "Saving session custom entity no rewrite match test (issue #4573)", False), ("alert_feed", test_alert_feed, "Alert feed tests", False), ("fox_api", run_fox_api_tests, "Fox API tests", False), ("deye_const", run_deye_const_tests, "DEYE constants tests", False), From 124927b16967d7e1dad731a51f9d7ecae61869f3 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Wed, 19 Aug 2026 08:38:18 +0100 Subject: [PATCH 2/2] fix: address review feedback on saving session entity fallback - Distinguish "attribute genuinely missing" from "attribute present but an empty list" using a sentinel, so the binary_sensor -> event fallback no longer misfires when the configured entity is valid but currently has no events (a normal, common state). - Isolate the new regression test's mutation of my_predbat.args behind a save/restore, matching the pattern used by other tests in this suite. Addresses Copilot review comments on PR #4585. --- apps/predbat/octopus.py | 24 ++++++--- apps/predbat/tests/test_saving_session.py | 65 ++++++++++++----------- 2 files changed, 50 insertions(+), 39 deletions(-) diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index ef0c5a927..ba8acfb98 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -35,6 +35,10 @@ DATE_STR_FORMAT = "%Y-%m-%d" DATE_TIME_STR_FORMAT = "%Y-%m-%dT%H:%M:%S%z" +# Sentinel distinguishing "attribute not present on the entity" from a genuinely empty list, +# since both would otherwise look the same (falsy) to callers. +_ATTRIBUTE_UNSET = object() + # Night-rate window definitions: start time, end time, whether the window crosses midnight. # Keys: "eco7" (Economy 7), "go" (Octopus GO / generic day-night), "iog" (Intelligent GO TOU). OCTOPUS_NIGHT_RATE_WINDOWS = { @@ -2939,18 +2943,22 @@ def fetch_octopus_sessions(self, axle_sessions=None): entity_id = self.get_arg("octopus_saving_session", indirect=False) if entity_id: state = self.get_arg("octopus_saving_session", False) - joined_events = self.get_state_wrapper(entity_id=entity_id, attribute="joined_events") - available_events = self.get_state_wrapper(entity_id=entity_id, attribute="available_events") - if not joined_events and not available_events: - # Legacy binary_sensor entities carry neither attribute - fall back to the - # newer event entity naming convention, but only adopt it if it actually has data + joined_events = self.get_state_wrapper(entity_id=entity_id, attribute="joined_events", default=_ATTRIBUTE_UNSET) + available_events = self.get_state_wrapper(entity_id=entity_id, attribute="available_events", default=_ATTRIBUTE_UNSET) + if joined_events is _ATTRIBUTE_UNSET and available_events is _ATTRIBUTE_UNSET: + # Legacy binary_sensor entities carry neither attribute at all - fall back to the + # newer event entity naming convention, but only adopt it if it actually has data. + # A configured entity that has the attributes but with no events right now (empty + # lists) is a valid state and must not trigger this fallback. fallback_entity_id = entity_id.replace("binary_sensor.", "event.").replace("_sessions", "_session_events") - fallback_joined_events = self.get_state_wrapper(entity_id=fallback_entity_id, attribute="joined_events") - fallback_available_events = self.get_state_wrapper(entity_id=fallback_entity_id, attribute="available_events") - if fallback_joined_events or fallback_available_events: + fallback_joined_events = self.get_state_wrapper(entity_id=fallback_entity_id, attribute="joined_events", default=_ATTRIBUTE_UNSET) + fallback_available_events = self.get_state_wrapper(entity_id=fallback_entity_id, attribute="available_events", default=_ATTRIBUTE_UNSET) + if fallback_joined_events is not _ATTRIBUTE_UNSET or fallback_available_events is not _ATTRIBUTE_UNSET: entity_id = fallback_entity_id joined_events = fallback_joined_events available_events = fallback_available_events + joined_events = [] if joined_events is _ATTRIBUTE_UNSET else joined_events + available_events = [] if available_events is _ATTRIBUTE_UNSET else available_events if available_events and not self.get_arg("octopus_saving_auto_join", True): self.log("Octopus: Saving session auto-join is disabled, not joining available events") diff --git a/apps/predbat/tests/test_saving_session.py b/apps/predbat/tests/test_saving_session.py index 19a270b3d..481459d81 100644 --- a/apps/predbat/tests/test_saving_session.py +++ b/apps/predbat/tests/test_saving_session.py @@ -624,40 +624,43 @@ def test_saving_session_custom_entity_no_rewrite_match(my_predbat): friendly_name: Predbat Octopus Power Down For Predbat """ - ha.dummy_items.clear() - ha.dummy_items["binary_sensor.predbat_octopus_power_down_for_predbat"] = yaml.safe_load(session_binary) - ha.dummy_items["sensor.octopus_free_session"] = {} - my_predbat.args["octopus_saving_session"] = "binary_sensor.predbat_octopus_power_down_for_predbat" - my_predbat.args["octopus_free_session"] = "sensor.octopus_free_session" - if "octopus_free_url" in my_predbat.args: - del my_predbat.args["octopus_free_url"] - if "octopus_saving_session_join" in my_predbat.args: - del my_predbat.args["octopus_saving_session_join"] - my_predbat.args["octopus_saving_session_octopoints_per_penny"] = 10 - # Reset throttle so a join is attempted - my_predbat.octopus_last_joined_try = None - - ha.service_store_enable = True - ha.service_store = [] - my_predbat.fetch_octopus_sessions() - service_result = ha.get_service_store() - ha.service_store_enable = False + saved_args = my_predbat.args.copy() + try: + ha.dummy_items.clear() + ha.dummy_items["binary_sensor.predbat_octopus_power_down_for_predbat"] = yaml.safe_load(session_binary) + ha.dummy_items["sensor.octopus_free_session"] = {} + my_predbat.args["octopus_saving_session"] = "binary_sensor.predbat_octopus_power_down_for_predbat" + my_predbat.args["octopus_free_session"] = "sensor.octopus_free_session" + if "octopus_free_url" in my_predbat.args: + del my_predbat.args["octopus_free_url"] + if "octopus_saving_session_join" in my_predbat.args: + del my_predbat.args["octopus_saving_session_join"] + my_predbat.args["octopus_saving_session_octopoints_per_penny"] = 10 + # Reset throttle so a join is attempted + my_predbat.octopus_last_joined_try = None - join_calls = [svc for svc in service_result if "join" in svc[0]] - if len(join_calls) != 1: - print(f"ERROR: Expected 1 join call reading available_events from the configured entity, got {len(join_calls)}: {service_result}") - failed = True - elif join_calls[0][1].get("entity_id") != "binary_sensor.predbat_octopus_power_down_for_predbat": - print(f"ERROR: Expected join call to use the configured entity, got {join_calls[0][1]}") - failed = True - else: - print(" PASS: available_events read from the configured entity despite no rewrite match") + ha.service_store_enable = True + ha.service_store = [] + my_predbat.fetch_octopus_sessions() + service_result = ha.get_service_store() + ha.service_store_enable = False - if not failed: - print("PASS: Custom entity name (no rewrite match) auto-join test passed") + join_calls = [svc for svc in service_result if "join" in svc[0]] + if len(join_calls) != 1: + print(f"ERROR: Expected 1 join call reading available_events from the configured entity, got {len(join_calls)}: {service_result}") + failed = True + elif join_calls[0][1].get("entity_id") != "binary_sensor.predbat_octopus_power_down_for_predbat": + print(f"ERROR: Expected join call to use the configured entity, got {join_calls[0][1]}") + failed = True + else: + print(" PASS: available_events read from the configured entity despite no rewrite match") - # Restore default throttle state so we do not leak it to other tests - my_predbat.octopus_last_joined_try = None + if not failed: + print("PASS: Custom entity name (no rewrite match) auto-join test passed") + finally: + my_predbat.args = saved_args + # Restore default throttle state so we do not leak it to other tests + my_predbat.octopus_last_joined_try = None return failed