From 511637c6fec9772cb3e9c6edc9b0cce4a265692e Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sat, 17 Oct 2026 11:51:54 +0200 Subject: [PATCH 1/5] docs: document observed chargingType values --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index 17347cf..88023cd 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,23 @@ with the default vehicle prefix: `saic//vehicles/` | /refresh/period/inActiveGrace/set | grace period (sec) | After the vehicle has been shutdown, the gateway continues to query the state for 600 seconds (default value). The duration of this extended query period can be modified with this topic. | | /location/findMyCar/set | [activate,lights_only,horn_only,stop] | Activate 'find my car' with lights and horn (activate), with lights only (lights_only), with horn only (horn_only) or deactivate it (stop). | +## Charging type values + +The `/drivetrain/chargingType` topic publishes the raw `chargingType` value reported by the SAIC API. The API does not +document it. The values below were observed by users (see [#398](https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/issues/398)) +and may differ between models. + +| Value | Observed meaning | +|-------|-------------------------------------------------------------------------| +| 0 | Car in deep sleep | +| 1 | Car just parked | +| 3 | HV battery in use (driving, or remote A/C on) | +| 6 | AC charging | +| 7 | DC charging | +| 9 | Charging stopped from the infotainment | +| 10 | Charging ended | +| 16 | Car unplugged while charging was still in progress | + ## Home Assistant auto-discovery The gateway supports [Home Assistant MQTT discovery](https://www.home-assistant.io/integrations/mqtt#mqtt-discovery). It From 201015c74738c8c5393950ae18079edd46dacb9f Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sat, 17 Oct 2026 11:56:22 +0200 Subject: [PATCH 2/5] docs: check chargingType values against logged data --- README.md | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 88023cd..50eec27 100644 --- a/README.md +++ b/README.md @@ -232,19 +232,22 @@ with the default vehicle prefix: `saic//vehicles/` ## Charging type values The `/drivetrain/chargingType` topic publishes the raw `chargingType` value reported by the SAIC API. The API does not -document it. The values below were observed by users (see [#398](https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/issues/398)) -and may differ between models. - -| Value | Observed meaning | -|-------|-------------------------------------------------------------------------| -| 0 | Car in deep sleep | -| 1 | Car just parked | -| 3 | HV battery in use (driving, or remote A/C on) | -| 6 | AC charging | -| 7 | DC charging | -| 9 | Charging stopped from the infotainment | -| 10 | Charging ended | -| 16 | Car unplugged while charging was still in progress | +document it. The meanings below were reported by users in +[#398](https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/issues/398) and checked against about 74k logged +samples from one vehicle. They may differ between models. + +| Value | Reported meaning | Checked against logged data | +|-------|--------------------------------------------------------|------------------------------------------------------| +| 0 | Car in deep sleep | Not confirmed (22 samples) | +| 1 | Car just parked | Consistent (near-zero battery current) | +| 3 | HV battery in use (driving, or remote A/C on) | Consistent (battery current under load) | +| 6 | AC charging | Confirmed (on-board charger input current present) | +| 7 | DC charging | Confirmed (about 200 A battery current, no AC input) | +| 9 | Charging stopped from the infotainment | Not confirmed (2 samples) | +| 10 | Charging ended | Consistent, few samples (24) | +| 16 | Car unplugged while charging was still in progress | Never observed | + +Values 2, 4 and 12 were seen only 1 to 2 times each, so their meaning is unknown. ## Home Assistant auto-discovery From 37db37fa504a2fe8291c69c492fc05aaa607df70 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sat, 17 Oct 2026 11:59:16 +0200 Subject: [PATCH 3/5] fix: log SAIC API timeouts as a short warning Timeouts from the SAIC API are server-side and transient. Log them as a one-line warning in the vehicle poll, message poll and command handlers. The traceback is only included when debug logging is enabled. Refs #390 --- src/handlers/message.py | 11 ++++++---- src/handlers/vehicle.py | 11 +++++++--- src/handlers/vehicle_command.py | 5 ++++- src/utils.py | 16 ++++++++++++++ tests/test_utils.py | 37 ++++++++++++++++++++++++++++++++- 5 files changed, 71 insertions(+), 9 deletions(-) diff --git a/src/handlers/message.py b/src/handlers/message.py index 64bf508..30b625c 100644 --- a/src/handlers/message.py +++ b/src/handlers/message.py @@ -6,7 +6,7 @@ from saic_ismart_client_ng.exceptions import SaicApiException, SaicLogoutException -from utils import ensure_datetime_aware +from utils import ensure_datetime_aware, is_api_timeout, log_api_timeout from vehicle import RefreshMode if TYPE_CHECKING: @@ -84,9 +84,12 @@ async def __polling(self) -> None: ) self.relogin_handler.relogin() except SaicApiException as e: - LOG.exception( - "MessageHandler poll loop failed during SAIC API Call", exc_info=e - ) + if is_api_timeout(e): + log_api_timeout(LOG, "MessageHandler poll failed, will retry", e) + else: + LOG.exception( + "MessageHandler poll loop failed during SAIC API Call", exc_info=e + ) except Exception as e: LOG.exception("MessageHandler poll loop failed unexpectedly", exc_info=e) diff --git a/src/handlers/vehicle.py b/src/handlers/vehicle.py index 7f18908..9dd777a 100644 --- a/src/handlers/vehicle.py +++ b/src/handlers/vehicle.py @@ -18,6 +18,7 @@ import mqtt_topics from saic_api_listener import MqttGatewayAbrpListener, MqttGatewayOsmAndListener from status_publisher.vehicle_info import VehicleInfoPublisher +from utils import is_api_timeout, log_api_timeout from vehicle import RefreshMode if TYPE_CHECKING: @@ -168,9 +169,13 @@ async def handle_vehicle(self) -> None: self.relogin_handler.relogin() except SaicApiException as e: self.vehicle_state.mark_failed_refresh() - LOG.exception( - "handle_vehicle loop failed during SAIC API call", exc_info=e - ) + if is_api_timeout(e): + log_api_timeout(LOG, "Vehicle poll failed, will retry", e) + else: + LOG.exception( + "handle_vehicle loop failed during SAIC API call", + exc_info=e, + ) except VehicleStatusDriftException as e: self.vehicle_state.mark_failed_refresh() LOG.error( diff --git a/src/handlers/vehicle_command.py b/src/handlers/vehicle_command.py index e1840d1..48ac38c 100644 --- a/src/handlers/vehicle_command.py +++ b/src/handlers/vehicle_command.py @@ -9,6 +9,7 @@ from exceptions import MqttGatewayException from handlers.command import ALL_COMMAND_HANDLERS, CommandHandlerBase import mqtt_topics +from utils import is_api_timeout, log_api_timeout from vehicle import RefreshMode if TYPE_CHECKING: @@ -65,7 +66,9 @@ def __report_command_failure( detail: str, exc: Exception | None = None, ) -> None: - if exc is not None: + if exc is not None and is_api_timeout(exc): + log_api_timeout(LOG, f"Command {command} failed", exc) + elif exc is not None: LOG.exception("Command %s failed: %s", command, detail, exc_info=exc) else: LOG.error("Command %s failed: %s", command, detail) diff --git a/src/utils.py b/src/utils.py index 7c0778e..4bd5ed5 100644 --- a/src/utils.py +++ b/src/utils.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING from zoneinfo import ZoneInfo +import httpx from saic_ismart_client_ng.api.schema import GpsStatus if TYPE_CHECKING: @@ -16,6 +17,21 @@ LOG = logging.getLogger(__name__) +def is_api_timeout(exc: BaseException) -> bool: + """Whether ``exc`` was caused by an HTTP timeout talking to the SAIC API.""" + return isinstance(exc.__cause__, httpx.TimeoutException) + + +def log_api_timeout(logger: logging.Logger, context: str, exc: BaseException) -> None: + """Log an API timeout as a short warning; keep the traceback for debug.""" + logger.warning( + "%s: SAIC API timed out, the server is not answering or busy: %s", + context, + exc, + exc_info=exc if logger.isEnabledFor(logging.DEBUG) else None, + ) + + def parse_timezone(tz_str: str) -> ZoneInfo: """Parse a timezone string into a :class:`ZoneInfo`. diff --git a/tests/test_utils.py b/tests/test_utils.py index fb2ac5c..c7a11ff 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,14 +1,22 @@ from __future__ import annotations import datetime +import logging from unittest import TestCase from zoneinfo import ZoneInfo +import httpx import pytest from saic_ismart_client_ng.api.schema import GpsPosition, GpsStatus from saic_ismart_client_ng.api.vehicle import VehicleStatusResp +from saic_ismart_client_ng.exceptions import SaicApiException -from utils import get_update_timestamp, parse_timezone +from utils import ( + get_update_timestamp, + is_api_timeout, + log_api_timeout, + parse_timezone, +) class Test(TestCase): @@ -136,3 +144,30 @@ def test_parses_gmt_negative_offset(self) -> None: def test_rejects_unknown_format(self) -> None: with pytest.raises(ValueError, match="Unrecognized timezone format"): parse_timezone("not-a-timezone") + + +class TestApiTimeoutHelpers: + @staticmethod + def _wrapped(cause: BaseException | None) -> SaicApiException: + exc = SaicApiException("failed", return_code=500) + exc.__cause__ = cause + return exc + + def test_is_api_timeout_true_for_httpx_timeout_cause(self) -> None: + assert is_api_timeout(self._wrapped(httpx.ReadTimeout("t"))) + + def test_is_api_timeout_false_for_other_cause(self) -> None: + assert not is_api_timeout(self._wrapped(httpx.ConnectError("c"))) + assert not is_api_timeout(self._wrapped(None)) + + def test_log_api_timeout_traceback_only_in_debug( + self, caplog: pytest.LogCaptureFixture + ) -> None: + logger = logging.getLogger("test_api_timeout") + exc = self._wrapped(httpx.ReadTimeout("t")) + with caplog.at_level(logging.INFO, logger=logger.name): + log_api_timeout(logger, "ctx", exc) + assert caplog.records[-1].exc_info is None + with caplog.at_level(logging.DEBUG, logger=logger.name): + log_api_timeout(logger, "ctx", exc) + assert caplog.records[-1].exc_info is not None From a22e33273848add92db6a423418393052e3f0cfe Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sat, 17 Oct 2026 12:00:36 +0200 Subject: [PATCH 4/5] Revert "fix: log SAIC API timeouts as a short warning" This reverts commit 37db37fa504a2fe8291c69c492fc05aaa607df70. --- src/handlers/message.py | 11 ++++------ src/handlers/vehicle.py | 11 +++------- src/handlers/vehicle_command.py | 5 +---- src/utils.py | 16 -------------- tests/test_utils.py | 37 +-------------------------------- 5 files changed, 9 insertions(+), 71 deletions(-) diff --git a/src/handlers/message.py b/src/handlers/message.py index 30b625c..64bf508 100644 --- a/src/handlers/message.py +++ b/src/handlers/message.py @@ -6,7 +6,7 @@ from saic_ismart_client_ng.exceptions import SaicApiException, SaicLogoutException -from utils import ensure_datetime_aware, is_api_timeout, log_api_timeout +from utils import ensure_datetime_aware from vehicle import RefreshMode if TYPE_CHECKING: @@ -84,12 +84,9 @@ async def __polling(self) -> None: ) self.relogin_handler.relogin() except SaicApiException as e: - if is_api_timeout(e): - log_api_timeout(LOG, "MessageHandler poll failed, will retry", e) - else: - LOG.exception( - "MessageHandler poll loop failed during SAIC API Call", exc_info=e - ) + LOG.exception( + "MessageHandler poll loop failed during SAIC API Call", exc_info=e + ) except Exception as e: LOG.exception("MessageHandler poll loop failed unexpectedly", exc_info=e) diff --git a/src/handlers/vehicle.py b/src/handlers/vehicle.py index 9dd777a..7f18908 100644 --- a/src/handlers/vehicle.py +++ b/src/handlers/vehicle.py @@ -18,7 +18,6 @@ import mqtt_topics from saic_api_listener import MqttGatewayAbrpListener, MqttGatewayOsmAndListener from status_publisher.vehicle_info import VehicleInfoPublisher -from utils import is_api_timeout, log_api_timeout from vehicle import RefreshMode if TYPE_CHECKING: @@ -169,13 +168,9 @@ async def handle_vehicle(self) -> None: self.relogin_handler.relogin() except SaicApiException as e: self.vehicle_state.mark_failed_refresh() - if is_api_timeout(e): - log_api_timeout(LOG, "Vehicle poll failed, will retry", e) - else: - LOG.exception( - "handle_vehicle loop failed during SAIC API call", - exc_info=e, - ) + LOG.exception( + "handle_vehicle loop failed during SAIC API call", exc_info=e + ) except VehicleStatusDriftException as e: self.vehicle_state.mark_failed_refresh() LOG.error( diff --git a/src/handlers/vehicle_command.py b/src/handlers/vehicle_command.py index 48ac38c..e1840d1 100644 --- a/src/handlers/vehicle_command.py +++ b/src/handlers/vehicle_command.py @@ -9,7 +9,6 @@ from exceptions import MqttGatewayException from handlers.command import ALL_COMMAND_HANDLERS, CommandHandlerBase import mqtt_topics -from utils import is_api_timeout, log_api_timeout from vehicle import RefreshMode if TYPE_CHECKING: @@ -66,9 +65,7 @@ def __report_command_failure( detail: str, exc: Exception | None = None, ) -> None: - if exc is not None and is_api_timeout(exc): - log_api_timeout(LOG, f"Command {command} failed", exc) - elif exc is not None: + if exc is not None: LOG.exception("Command %s failed: %s", command, detail, exc_info=exc) else: LOG.error("Command %s failed: %s", command, detail) diff --git a/src/utils.py b/src/utils.py index 4bd5ed5..7c0778e 100644 --- a/src/utils.py +++ b/src/utils.py @@ -8,7 +8,6 @@ from typing import TYPE_CHECKING from zoneinfo import ZoneInfo -import httpx from saic_ismart_client_ng.api.schema import GpsStatus if TYPE_CHECKING: @@ -17,21 +16,6 @@ LOG = logging.getLogger(__name__) -def is_api_timeout(exc: BaseException) -> bool: - """Whether ``exc`` was caused by an HTTP timeout talking to the SAIC API.""" - return isinstance(exc.__cause__, httpx.TimeoutException) - - -def log_api_timeout(logger: logging.Logger, context: str, exc: BaseException) -> None: - """Log an API timeout as a short warning; keep the traceback for debug.""" - logger.warning( - "%s: SAIC API timed out, the server is not answering or busy: %s", - context, - exc, - exc_info=exc if logger.isEnabledFor(logging.DEBUG) else None, - ) - - def parse_timezone(tz_str: str) -> ZoneInfo: """Parse a timezone string into a :class:`ZoneInfo`. diff --git a/tests/test_utils.py b/tests/test_utils.py index c7a11ff..fb2ac5c 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,22 +1,14 @@ from __future__ import annotations import datetime -import logging from unittest import TestCase from zoneinfo import ZoneInfo -import httpx import pytest from saic_ismart_client_ng.api.schema import GpsPosition, GpsStatus from saic_ismart_client_ng.api.vehicle import VehicleStatusResp -from saic_ismart_client_ng.exceptions import SaicApiException -from utils import ( - get_update_timestamp, - is_api_timeout, - log_api_timeout, - parse_timezone, -) +from utils import get_update_timestamp, parse_timezone class Test(TestCase): @@ -144,30 +136,3 @@ def test_parses_gmt_negative_offset(self) -> None: def test_rejects_unknown_format(self) -> None: with pytest.raises(ValueError, match="Unrecognized timezone format"): parse_timezone("not-a-timezone") - - -class TestApiTimeoutHelpers: - @staticmethod - def _wrapped(cause: BaseException | None) -> SaicApiException: - exc = SaicApiException("failed", return_code=500) - exc.__cause__ = cause - return exc - - def test_is_api_timeout_true_for_httpx_timeout_cause(self) -> None: - assert is_api_timeout(self._wrapped(httpx.ReadTimeout("t"))) - - def test_is_api_timeout_false_for_other_cause(self) -> None: - assert not is_api_timeout(self._wrapped(httpx.ConnectError("c"))) - assert not is_api_timeout(self._wrapped(None)) - - def test_log_api_timeout_traceback_only_in_debug( - self, caplog: pytest.LogCaptureFixture - ) -> None: - logger = logging.getLogger("test_api_timeout") - exc = self._wrapped(httpx.ReadTimeout("t")) - with caplog.at_level(logging.INFO, logger=logger.name): - log_api_timeout(logger, "ctx", exc) - assert caplog.records[-1].exc_info is None - with caplog.at_level(logging.DEBUG, logger=logger.name): - log_api_timeout(logger, "ctx", exc) - assert caplog.records[-1].exc_info is not None From e2bbc7af85540ca009611e813eee40a7f16082bd Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sat, 17 Oct 2026 12:00:36 +0200 Subject: [PATCH 5/5] docs: ground chargingType values in raw API responses --- README.md | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 50eec27..23b64d7 100644 --- a/README.md +++ b/README.md @@ -233,21 +233,22 @@ with the default vehicle prefix: `saic//vehicles/` The `/drivetrain/chargingType` topic publishes the raw `chargingType` value reported by the SAIC API. The API does not document it. The meanings below were reported by users in -[#398](https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/issues/398) and checked against about 74k logged -samples from one vehicle. They may differ between models. - -| Value | Reported meaning | Checked against logged data | -|-------|--------------------------------------------------------|------------------------------------------------------| -| 0 | Car in deep sleep | Not confirmed (22 samples) | -| 1 | Car just parked | Consistent (near-zero battery current) | -| 3 | HV battery in use (driving, or remote A/C on) | Consistent (battery current under load) | -| 6 | AC charging | Confirmed (on-board charger input current present) | -| 7 | DC charging | Confirmed (about 200 A battery current, no AC input) | -| 9 | Charging stopped from the infotainment | Not confirmed (2 samples) | -| 10 | Charging ended | Consistent, few samples (24) | -| 16 | Car unplugged while charging was still in progress | Never observed | - -Values 2, 4 and 12 were seen only 1 to 2 times each, so their meaning is unknown. +[#398](https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/issues/398) and checked against about 89k logged +API responses from one vehicle (Mar 2024 – Oct 2026). They may differ between models. + +| Value | Reported meaning | Checked against logged data | +|-------|----------------------------------------------------|----------------------------------------------------------------------------------| +| 0 | Car in deep sleep | Not supported: 31 isolated samples between values 1 and 3, not a sustained state | +| 1 | Car just parked | Consistent (near-zero battery current) | +| 3 | HV battery in use (driving, or remote A/C on) | Consistent (battery current under load) | +| 6 | AC charging | Confirmed (on-board charger input current present) | +| 7 | DC charging | Confirmed (about 200 A battery current, no AC input) | +| 9 | Charging stopped from the infotainment | Unclear: both samples follow DC charging while plugged in | +| 10 | Charging ended | Supported: 17 of 24 samples follow AC charging while plugged in | +| 16 | Car unplugged while charging was still in progress | Never observed | + +Values 2, 4 and 12 were seen only 1 to 3 times each. 4 and 12 appeared around the start or end of charging, which could +mean a transition state. Their meaning is unknown. ## Home Assistant auto-discovery