From ddc119f19a5634617e7c83a7edc15ca771aceb36 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sat, 17 Oct 2026 12:06:29 +0200 Subject: [PATCH 1/2] fix: tolerate transient poll failures before marking vehicle offline --- src/mqtt_gateway.py | 2 +- src/vehicle.py | 14 ++++++++++++-- tests/test_vehicle_state.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/mqtt_gateway.py b/src/mqtt_gateway.py index f955e93..40b8f5b 100644 --- a/src/mqtt_gateway.py +++ b/src/mqtt_gateway.py @@ -316,7 +316,7 @@ async def __refresh_vehicle_list(self) -> None: for vin in known_vins - api_vins: LOG.warning("Vehicle %s no longer in API vehicle list, stopping", vin) vh = self.vehicle_handlers.pop(vin) - vh.vehicle_state.mark_failed_refresh() + vh.vehicle_state.mark_failed_refresh(force_offline=True) task = self.__cancel_vehicle_task(vin) if task is not None: with contextlib.suppress(asyncio.CancelledError): diff --git a/src/vehicle.py b/src/vehicle.py index 3c73742..afa1276 100644 --- a/src/vehicle.py +++ b/src/vehicle.py @@ -44,6 +44,8 @@ DEFAULT_AC_TEMP = 22 PRESSURE_TO_BAR_FACTOR = 0.04 +# Consecutive failed polls before the vehicle is marked offline +VEHICLE_FAILURES_BEFORE_OFFLINE = 3 # Consecutive charge data failures before charging entities are marked unavailable CHARGE_DATA_FAILURES_BEFORE_UNAVAILABLE = 3 @@ -115,6 +117,7 @@ def __init__( ) self.__last_failed_refresh: datetime.datetime | None = None self.__failed_refresh_counter = 0 + self.__consecutive_failed_refreshes = 0 self.__failed_charge_data_counter = 0 self.__charge_data_available: bool | None = None self.__refresh_period_error = 30 @@ -476,6 +479,7 @@ def __should_do_periodic_refresh(self) -> bool: def mark_successful_refresh(self) -> None: self.last_successful_refresh = datetime.datetime.now(tz=datetime.UTC) self.last_failed_refresh = None + self.__consecutive_failed_refreshes = 0 self.publisher.publish_str(self.get_topic(mqtt_topics.AVAILABLE), "online") def mark_charge_data_received(self) -> None: @@ -496,9 +500,15 @@ def __publish_charge_data_available(self, *, available: bool) -> None: "online" if available else "offline", ) - def mark_failed_refresh(self) -> None: + def mark_failed_refresh(self, *, force_offline: bool = False) -> None: self.last_failed_refresh = datetime.datetime.now(tz=datetime.UTC) - self.publisher.publish_str(self.get_topic(mqtt_topics.AVAILABLE), "offline") + self.__consecutive_failed_refreshes += 1 + # Tolerate a few intermittent failures before flipping entities to unavailable + if ( + force_offline + or self.__consecutive_failed_refreshes >= VEHICLE_FAILURES_BEFORE_OFFLINE + ): + self.publisher.publish_str(self.get_topic(mqtt_topics.AVAILABLE), "offline") @property def refresh_period_error(self) -> int: diff --git a/tests/test_vehicle_state.py b/tests/test_vehicle_state.py index b92f0a2..2cc8983 100644 --- a/tests/test_vehicle_state.py +++ b/tests/test_vehicle_state.py @@ -55,6 +55,34 @@ def _charge_data_availability(self) -> str | None: topic = f"/vehicles/{VIN}/{mqtt_topics.DRIVETRAIN_CHARGING_DATA_AVAILABLE}" return self.publisher.map.get(topic) + def _vehicle_availability(self) -> str | None: + return self.publisher.map.get(f"/vehicles/{VIN}/{mqtt_topics.AVAILABLE}") + + def test_vehicle_offline_only_after_repeated_failures(self) -> None: + self.vehicle_state.mark_successful_refresh() + assert self._vehicle_availability() == "online" + + self.vehicle_state.mark_failed_refresh() + self.vehicle_state.mark_failed_refresh() + assert self._vehicle_availability() == "online" + + self.vehicle_state.mark_failed_refresh() + assert self._vehicle_availability() == "offline" + + def test_successful_refresh_resets_vehicle_failure_count(self) -> None: + self.vehicle_state.mark_successful_refresh() + self.vehicle_state.mark_failed_refresh() + self.vehicle_state.mark_failed_refresh() + self.vehicle_state.mark_successful_refresh() + self.vehicle_state.mark_failed_refresh() + self.vehicle_state.mark_failed_refresh() + assert self._vehicle_availability() == "online" + + def test_force_offline_is_immediate(self) -> None: + self.vehicle_state.mark_successful_refresh() + self.vehicle_state.mark_failed_refresh(force_offline=True) + assert self._vehicle_availability() == "offline" + def test_charge_data_unavailable_only_after_repeated_failures(self) -> None: self.vehicle_state.mark_charge_data_received() assert self._charge_data_availability() == "online" From 623f8b90b8c172b244f982622c455818bb5ba27e Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sat, 17 Oct 2026 14:48:22 +0200 Subject: [PATCH 2/2] docs: describe tolerance of transient poll failures --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 23b64d7..b12f440 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ The following are the known available endpoints: |-----------------------|----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | --ha-discovery | HA_DISCOVERY_ENABLED | Home Assistant auto-discovery is enabled (True) by default. It can be disabled (False) with this parameter. | | --ha-discovery-prefix | HA_DISCOVERY_PREFIX | The default MQTT prefix for Home Assistant auto-discovery is 'homeassistant'. Another prefix can be configured with this parameter | -| --ha-show-unavailable | HA_SHOW_UNAVAILABLE | Show entities as Unavailable in Home Assistant when car polling fails. Enabled (True) by default. Can be disabled, to retain the pre 0.6.x behaviour, but do that at your own risk. | +| --ha-show-unavailable | HA_SHOW_UNAVAILABLE | Show entities as Unavailable in Home Assistant after 3 consecutive failed polls (a single failed poll is tolerated). Enabled (True) by default. Can be disabled, to retain the pre 0.6.x behaviour, but do that at your own risk. | ### A Better Route Planner (ABRP) integration