From abd2212f43f0fc7a82e2ac99de25c95601eb57ec Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sun, 15 Mar 2026 19:51:38 +0100 Subject: [PATCH 1/2] fix: deduplicate pollingPhase MQTT publishes __publish_polling_phase() was called on every polling cycle via should_refresh(), publishing the phase to MQTT even when unchanged. Track the current phase and skip redundant publishes, matching the existing deduplication pattern in set_refresh_mode(). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/vehicle.py | 4 ++++ tests/mocks/__init__.py | 2 ++ tests/test_vehicle_state.py | 13 +++++++++++++ 3 files changed, 19 insertions(+) diff --git a/src/vehicle.py b/src/vehicle.py index f20630e8..669e1a54 100644 --- a/src/vehicle.py +++ b/src/vehicle.py @@ -130,6 +130,7 @@ def __init__( self.charge_polling_min_percent = charge_polling_min_percent self.refresh_mode = RefreshMode.OFF self.previous_refresh_mode = RefreshMode.OFF + self.__polling_phase: PollingPhase | None = None self.__remote_ac_temp: int | None = None self.__remote_ac_running: bool = False self.__remote_heated_seats_front_left_level: int = 0 @@ -710,6 +711,9 @@ def get_topic(self, sub_topic: str) -> str: return f"{self.mqtt_vin_prefix}/{sub_topic}" def __publish_polling_phase(self, phase: PollingPhase) -> None: + if self.__polling_phase == phase: + return + self.__polling_phase = phase self.publisher.publish_str( self.get_topic(mqtt_topics.REFRESH_POLLING_PHASE), phase.value ) diff --git a/tests/mocks/__init__.py b/tests/mocks/__init__.py index 0818d803..9790d3a3 100644 --- a/tests/mocks/__init__.py +++ b/tests/mocks/__init__.py @@ -15,8 +15,10 @@ class MessageCapturingConsolePublisher(ConsolePublisher): def __init__(self, configuration: Configuration) -> None: super().__init__(configuration) self.map: dict[str, Any] = {} + self.publish_count: dict[str, int] = {} @override def internal_publish(self, key: str, value: Any) -> None: self.map[key] = value + self.publish_count[key] = self.publish_count.get(key, 0) + 1 LOG.debug(f"{key}: {value}") diff --git a/tests/test_vehicle_state.py b/tests/test_vehicle_state.py index b1078aaf..283e4dca 100644 --- a/tests/test_vehicle_state.py +++ b/tests/test_vehicle_state.py @@ -486,6 +486,19 @@ def test_charging_stop_triggers_after_shutdown_grace(self) -> None: PollingPhase.AFTER_SHUTDOWN.value, ) + def test_polling_phase_not_republished_when_unchanged(self) -> None: + """Calling should_refresh() twice with the same state should only publish the phase once.""" + self.vehicle_state.configure_missing() + self.vehicle_state.set_refresh_mode(RefreshMode.OFF, "test") + self.publisher.map.clear() + self.publisher.publish_count.clear() + + self.vehicle_state.should_refresh() + self.vehicle_state.should_refresh() + + phase_topic = self.get_topic(mqtt_topics.REFRESH_POLLING_PHASE) + assert self.publisher.publish_count.get(phase_topic, 0) == 1 + @staticmethod def get_topic(sub_topic: str) -> str: return f"/vehicles/{VIN}/{sub_topic}" From fe798e7280765756ad345fae001ff2ce8b3d4a1b Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sun, 15 Mar 2026 19:54:39 +0100 Subject: [PATCH 2/2] test: add A->B->A transition test for polling phase dedup Verifies that the deduplication correctly re-publishes a phase after an intervening different phase (OFF -> FORCE -> OFF publishes 3 times). Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/test_vehicle_state.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_vehicle_state.py b/tests/test_vehicle_state.py index 283e4dca..e0a47cdd 100644 --- a/tests/test_vehicle_state.py +++ b/tests/test_vehicle_state.py @@ -499,6 +499,32 @@ def test_polling_phase_not_republished_when_unchanged(self) -> None: phase_topic = self.get_topic(mqtt_topics.REFRESH_POLLING_PHASE) assert self.publisher.publish_count.get(phase_topic, 0) == 1 + def test_polling_phase_republished_after_transition_back(self) -> None: + """A->B->A transition must publish all three phases (not suppress the return to A).""" + self.vehicle_state.configure_missing() + # Start with OFF (phase A) + self.vehicle_state.set_refresh_mode(RefreshMode.OFF, "test") + self.publisher.map.clear() + self.publisher.publish_count.clear() + + # Phase A: OFF + self.vehicle_state.should_refresh() + phase_topic = self.get_topic(mqtt_topics.REFRESH_POLLING_PHASE) + assert self.publisher.map[phase_topic] == PollingPhase.OFF.value + assert self.publisher.publish_count[phase_topic] == 1 + + # Phase B: FORCE (one-shot, reverts to PERIODIC) + self.vehicle_state.set_refresh_mode(RefreshMode.FORCE, "test") + self.vehicle_state.should_refresh() + assert self.publisher.map[phase_topic] == PollingPhase.FORCE.value + assert self.publisher.publish_count[phase_topic] == 2 + + # Phase A again: back to OFF + self.vehicle_state.set_refresh_mode(RefreshMode.OFF, "test") + self.vehicle_state.should_refresh() + assert self.publisher.map[phase_topic] == PollingPhase.OFF.value + assert self.publisher.publish_count[phase_topic] == 3 + @staticmethod def get_topic(sub_topic: str) -> str: return f"/vehicles/{VIN}/{sub_topic}"