From bce95f6d21636687177c07cbdc4ef161ae351445 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sat, 14 Mar 2026 10:00:24 +0100 Subject: [PATCH 1/5] feat: add HA Event entity for vehicle messages Publishes a vehicle_message event to Home Assistant whenever a new message arrives from the car (maintenance alerts, security notifications, etc.). The event includes title, content, message_type, sender, and vin attributes for use in automations. Deduplication is handled by the existing MessagePublisher guard that only processes messages with a newer timestamp than the last seen. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/integrations/home_assistant/discovery.py | 8 +- src/mqtt_topics.py | 3 + src/status_publisher/message.py | 24 +++ tests/status_publisher/__init__.py | 0 .../test_message_publisher.py | 152 ++++++++++++++++++ 5 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 tests/status_publisher/__init__.py create mode 100644 tests/status_publisher/test_message_publisher.py diff --git a/src/integrations/home_assistant/discovery.py b/src/integrations/home_assistant/discovery.py index ba1e9cd7..622d519d 100644 --- a/src/integrations/home_assistant/discovery.py +++ b/src/integrations/home_assistant/discovery.py @@ -327,7 +327,7 @@ def __publish_ha_discovery_messages_real(self) -> None: ) self.__publish_lights_sensors() - # Command error event + # Event entities self._publish_event( mqtt_topics.COMMAND_ERROR, "Command error", @@ -335,6 +335,12 @@ def __publish_ha_discovery_messages_real(self) -> None: entity_category="diagnostic", icon="mdi:alert-circle", ) + self._publish_event( + mqtt_topics.EVENTS_VEHICLE_MESSAGE, + "Vehicle message", + ["vehicle_message"], + icon="mdi:message-text", + ) LOG.debug("Completed publishing Home Assistant discovery messages") diff --git a/src/mqtt_topics.py b/src/mqtt_topics.py index 691a9c2c..cf91614f 100644 --- a/src/mqtt_topics.py +++ b/src/mqtt_topics.py @@ -182,4 +182,7 @@ COMMAND = "command" COMMAND_ERROR = COMMAND + "/error" +EVENTS = "events" +EVENTS_VEHICLE_MESSAGE = EVENTS + "/vehicleMessage" + VEHICLES = "vehicles" diff --git a/src/status_publisher/message.py b/src/status_publisher/message.py index 1db14977..948cf309 100644 --- a/src/status_publisher/message.py +++ b/src/status_publisher/message.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from datetime import UTC, datetime +import logging from typing import TYPE_CHECKING, override from saic_ismart_client_ng.api.message import MessageEntity @@ -13,6 +14,8 @@ from publisher.core import Publisher from vehicle_info import VehicleInfo +LOG = logging.getLogger(__name__) + @dataclass(kw_only=True, frozen=True) class MessagePublisherProcessingResult: @@ -82,5 +85,26 @@ def publish(self, message: MessageEntity) -> MessagePublisherProcessingResult: value=message.vin, ) + self.__publish_message_event(message) + return MessagePublisherProcessingResult(processed=True) return MessagePublisherProcessingResult(processed=False) + + def __publish_message_event(self, message: MessageEntity) -> None: + try: + self._publish( + topic=mqtt_topics.EVENTS_VEHICLE_MESSAGE, + value={ + "event_type": "vehicle_message", + "title": message.title or "", + "content": message.content or "", + "message_type": message.messageType or "", + "sender": message.sender or "", + "vin": message.vin or "", + }, + ) + except Exception: + LOG.warning( + "Failed to publish vehicle message event", + exc_info=True, + ) diff --git a/tests/status_publisher/__init__.py b/tests/status_publisher/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/status_publisher/test_message_publisher.py b/tests/status_publisher/test_message_publisher.py new file mode 100644 index 00000000..872011e6 --- /dev/null +++ b/tests/status_publisher/test_message_publisher.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import json +from typing import Any +import unittest + +from saic_ismart_client_ng.api.message import MessageEntity +from saic_ismart_client_ng.api.vehicle.schema import VinInfo + +from configuration import Configuration +import mqtt_topics +from status_publisher.message import MessagePublisher +from tests.mocks import MessageCapturingConsolePublisher +from vehicle_info import VehicleInfo + +VIN = "vin_test_000000000" +VEHICLE_PREFIX = f"vehicles/{VIN}" +EVENT_TOPIC = f"{VEHICLE_PREFIX}/{mqtt_topics.EVENTS_VEHICLE_MESSAGE}" + + +def _make_publisher() -> tuple[MessagePublisher, MessageCapturingConsolePublisher]: + config = Configuration() + config.anonymized_publishing = False + capturing_publisher = MessageCapturingConsolePublisher(config) + vin_info = VinInfo() + vin_info.vin = VIN + vehicle_info = VehicleInfo(vin_info, None) + msg_publisher = MessagePublisher(vehicle_info, capturing_publisher, VEHICLE_PREFIX) + return msg_publisher, capturing_publisher + + +def _make_message( + *, + message_id: str = "msg_001", + title: str = "Test Alert", + content: str = "Your vehicle has been started", + message_type: str = "323", + sender: str = "iSMART", + vin: str = VIN, + message_time: str = "2026-03-14 10:00:00", +) -> MessageEntity: + return MessageEntity( + messageId=message_id, + title=title, + content=content, + messageType=message_type, + sender=sender, + vin=vin, + messageTime=message_time, + ) + + +def _get_event(capturing: MessageCapturingConsolePublisher) -> dict[str, Any]: + raw = capturing.map[EVENT_TOPIC] + result: dict[str, Any] = json.loads(raw) + return result + + +class TestMessageEventPublished(unittest.TestCase): + def test_new_message_publishes_event(self) -> None: + publisher, capturing = _make_publisher() + + result = publisher.publish(_make_message()) + + assert result.processed is True + assert EVENT_TOPIC in capturing.map + event = _get_event(capturing) + assert event["event_type"] == "vehicle_message" + assert event["title"] == "Test Alert" + assert event["content"] == "Your vehicle has been started" + assert event["message_type"] == "323" + assert event["sender"] == "iSMART" + assert event["vin"] == VIN + + def test_duplicate_message_not_published(self) -> None: + publisher, capturing = _make_publisher() + msg = _make_message() + + publisher.publish(msg) + capturing.map.clear() + + result = publisher.publish(msg) + + assert result.processed is False + assert EVENT_TOPIC not in capturing.map + + def test_newer_message_publishes_event(self) -> None: + publisher, capturing = _make_publisher() + + publisher.publish(_make_message(message_time="2026-03-14 10:00:00")) + capturing.map.clear() + + result = publisher.publish( + _make_message( + message_id="msg_002", + title="Second Alert", + message_time="2026-03-14 11:00:00", + ) + ) + + assert result.processed is True + event = _get_event(capturing) + assert event["title"] == "Second Alert" + + def test_older_message_not_published(self) -> None: + publisher, capturing = _make_publisher() + + publisher.publish(_make_message(message_time="2026-03-14 10:00:00")) + capturing.map.clear() + + result = publisher.publish( + _make_message( + message_id="msg_old", + message_time="2026-03-14 09:00:00", + ) + ) + + assert result.processed is False + assert EVENT_TOPIC not in capturing.map + + +class TestMessageEventPayload(unittest.TestCase): + def test_none_fields_become_empty_strings(self) -> None: + publisher, capturing = _make_publisher() + + msg = MessageEntity( + messageId="msg_none", + messageTime="2026-03-14 10:00:00", + ) + publisher.publish(msg) + + event = _get_event(capturing) + assert event["title"] == "" + assert event["content"] == "" + assert event["message_type"] == "" + assert event["sender"] == "" + assert event["vin"] == "" + + def test_event_payload_keys(self) -> None: + publisher, capturing = _make_publisher() + + publisher.publish(_make_message()) + + event = _get_event(capturing) + assert set(event.keys()) == { + "event_type", + "title", + "content", + "message_type", + "sender", + "vin", + } From 97843dcd3f047eb34dd9a12ac766e07572b0ced1 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sat, 14 Mar 2026 10:04:07 +0100 Subject: [PATCH 2/5] fix: address review findings for vehicle message event - Document best-effort intent with comment - Upgrade LOG.warning to LOG.exception with message ID and VIN context - Add resilience test: event publish failure doesn't break processing Co-Authored-By: Claude Opus 4.6 (1M context) --- src/status_publisher/message.py | 9 ++++++--- .../test_message_publisher.py | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/status_publisher/message.py b/src/status_publisher/message.py index 948cf309..c91cf538 100644 --- a/src/status_publisher/message.py +++ b/src/status_publisher/message.py @@ -91,6 +91,8 @@ def publish(self, message: MessageEntity) -> MessagePublisherProcessingResult: return MessagePublisherProcessingResult(processed=False) def __publish_message_event(self, message: MessageEntity) -> None: + # Best-effort: a failure here must not prevent the individual + # topic publishes above from being reported as successful. try: self._publish( topic=mqtt_topics.EVENTS_VEHICLE_MESSAGE, @@ -104,7 +106,8 @@ def __publish_message_event(self, message: MessageEntity) -> None: }, ) except Exception: - LOG.warning( - "Failed to publish vehicle message event", - exc_info=True, + LOG.exception( + "Failed to publish vehicle message event for message %s (VIN: %s)", + message.messageId, + message.vin, ) diff --git a/tests/status_publisher/test_message_publisher.py b/tests/status_publisher/test_message_publisher.py index 872011e6..814bc2be 100644 --- a/tests/status_publisher/test_message_publisher.py +++ b/tests/status_publisher/test_message_publisher.py @@ -3,6 +3,7 @@ import json from typing import Any import unittest +from unittest.mock import patch from saic_ismart_client_ng.api.message import MessageEntity from saic_ismart_client_ng.api.vehicle.schema import VinInfo @@ -150,3 +151,22 @@ def test_event_payload_keys(self) -> None: "sender", "vin", } + + +class TestMessageEventResilience(unittest.TestCase): + def test_event_publish_failure_does_not_break_processing(self) -> None: + publisher, capturing = _make_publisher() + original_publish = publisher._publish_directly + + def failing_publish(*, topic: str, value: Any) -> bool: + if mqtt_topics.EVENTS_VEHICLE_MESSAGE in topic: + raise RuntimeError("MQTT down") + return original_publish(topic=topic, value=value) + + with patch.object(publisher, "_publish_directly", side_effect=failing_publish): + result = publisher.publish(_make_message()) + + assert result.processed is True + assert EVENT_TOPIC not in capturing.map + time_topic = f"{VEHICLE_PREFIX}/{mqtt_topics.INFO_LAST_MESSAGE_TIME}" + assert time_topic in capturing.map From eadfdecd37d09e559aa8c7d731a21b0732b03934 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sat, 14 Mar 2026 10:07:47 +0100 Subject: [PATCH 3/5] fix: publish event entities without MQTT retain flag HA Event entities show "Unknown" when they receive a retained message on connect because they cannot determine when the event occurred. Add a retain parameter to publish_json (default True for backward compat) and thread it through VehicleDataPublisher._publish. Both command_error and vehicle_message events now publish with retain=False. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/handlers/vehicle_command.py | 2 +- src/publisher/core.py | 7 ++++++- src/publisher/log_publisher.py | 7 ++++++- src/publisher/mqtt_publisher.py | 15 +++++++++++---- src/status_publisher/__init__.py | 11 ++++++++--- src/status_publisher/message.py | 1 + tests/status_publisher/test_message_publisher.py | 6 +++--- 7 files changed, 36 insertions(+), 13 deletions(-) diff --git a/src/handlers/vehicle_command.py b/src/handlers/vehicle_command.py index b0730eb4..04d617ea 100644 --- a/src/handlers/vehicle_command.py +++ b/src/handlers/vehicle_command.py @@ -84,7 +84,7 @@ def __report_command_failure( "command": command, "detail": detail, } - self.publisher.publish_json(error_topic, event_payload) + self.publisher.publish_json(error_topic, event_payload, retain=False) except Exception: LOG.warning( "Failed to publish command error event for command %s", diff --git a/src/publisher/core.py b/src/publisher/core.py index 574f6cff..b6628d83 100644 --- a/src/publisher/core.py +++ b/src/publisher/core.py @@ -75,7 +75,12 @@ def is_connected(self) -> bool: @abstractmethod def publish_json( - self, key: str, data: dict[str, Any], no_prefix: bool = False + self, + key: str, + data: dict[str, Any], + no_prefix: bool = False, + *, + retain: bool = True, ) -> None: raise NotImplementedError diff --git a/src/publisher/log_publisher.py b/src/publisher/log_publisher.py index efb6b249..00deb677 100644 --- a/src/publisher/log_publisher.py +++ b/src/publisher/log_publisher.py @@ -24,7 +24,12 @@ def enable_commands(self) -> None: @override def publish_json( - self, key: str, data: dict[str, Any], no_prefix: bool = False + self, + key: str, + data: dict[str, Any], + no_prefix: bool = False, + *, + retain: bool = True, ) -> None: anonymized_json = self.dict_to_anonymized_json(data) self.internal_publish(key, anonymized_json) diff --git a/src/publisher/mqtt_publisher.py b/src/publisher/mqtt_publisher.py index e93c6018..4f079fc4 100644 --- a/src/publisher/mqtt_publisher.py +++ b/src/publisher/mqtt_publisher.py @@ -221,8 +221,8 @@ async def __handle_imported_energy(self, topic: str, payload: str) -> None: vin, imported_energy_wh ) - def __publish(self, topic: str, payload: Any) -> None: - self.client.publish(topic, payload, retain=True) + def __publish(self, topic: str, payload: Any, *, retain: bool = True) -> None: + self.client.publish(topic, payload, retain=retain) @override def is_connected(self) -> bool: @@ -230,10 +230,17 @@ def is_connected(self) -> bool: @override def publish_json( - self, key: str, data: dict[str, Any], no_prefix: bool = False + self, + key: str, + data: dict[str, Any], + no_prefix: bool = False, + *, + retain: bool = True, ) -> None: payload = self.dict_to_anonymized_json(data) - self.__publish(topic=self.get_topic(key, no_prefix), payload=payload) + self.__publish( + topic=self.get_topic(key, no_prefix), payload=payload, retain=retain + ) @override def publish_str(self, key: str, value: str, no_prefix: bool = False) -> None: diff --git a/src/status_publisher/__init__.py b/src/status_publisher/__init__.py index adb4e695..c39dce92 100644 --- a/src/status_publisher/__init__.py +++ b/src/status_publisher/__init__.py @@ -35,11 +35,14 @@ def _publish( value: Publishable | None, validator: Callable[[Publishable], bool] = lambda _: True, no_prefix: bool = False, + retain: bool = True, ) -> tuple[bool, Publishable | None]: if value is None or not validator(value): return False, None actual_topic = topic if no_prefix else self.__get_topic(topic) - published = self._publish_directly(topic=actual_topic, value=value) + published = self._publish_directly( + topic=actual_topic, value=value, retain=retain + ) return published, value def _transform_and_publish( @@ -58,7 +61,9 @@ def _transform_and_publish( published = self._publish_directly(topic=actual_topic, value=transformed_value) return published, transformed_value - def _publish_directly(self, *, topic: str, value: Publishable) -> bool: + def _publish_directly( + self, *, topic: str, value: Publishable, retain: bool = True + ) -> bool: published = False if isinstance(value, bool): self.__publisher.publish_bool(topic, value) @@ -73,7 +78,7 @@ def _publish_directly(self, *, topic: str, value: Publishable) -> bool: self.__publisher.publish_str(topic, value) published = True elif isinstance(value, dict): - self.__publisher.publish_json(topic, value) + self.__publisher.publish_json(topic, value, retain=retain) published = True elif isinstance(value, datetime): self.__publisher.publish_str(topic, datetime_to_str(value)) diff --git a/src/status_publisher/message.py b/src/status_publisher/message.py index c91cf538..549cb424 100644 --- a/src/status_publisher/message.py +++ b/src/status_publisher/message.py @@ -104,6 +104,7 @@ def __publish_message_event(self, message: MessageEntity) -> None: "sender": message.sender or "", "vin": message.vin or "", }, + retain=False, ) except Exception: LOG.exception( diff --git a/tests/status_publisher/test_message_publisher.py b/tests/status_publisher/test_message_publisher.py index 814bc2be..8cdc598c 100644 --- a/tests/status_publisher/test_message_publisher.py +++ b/tests/status_publisher/test_message_publisher.py @@ -158,10 +158,10 @@ def test_event_publish_failure_does_not_break_processing(self) -> None: publisher, capturing = _make_publisher() original_publish = publisher._publish_directly - def failing_publish(*, topic: str, value: Any) -> bool: - if mqtt_topics.EVENTS_VEHICLE_MESSAGE in topic: + def failing_publish(**kwargs: Any) -> bool: + if mqtt_topics.EVENTS_VEHICLE_MESSAGE in kwargs["topic"]: raise RuntimeError("MQTT down") - return original_publish(topic=topic, value=value) + return original_publish(**kwargs) with patch.object(publisher, "_publish_directly", side_effect=failing_publish): result = publisher.publish(_make_message()) From a292210da94879c39888806d7586b890c00efd1d Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sat, 14 Mar 2026 10:09:16 +0100 Subject: [PATCH 4/5] fix: handle naive datetime comparison in message handler The SAIC API returns message timestamps as naive datetimes, but last_message_ts is initialized as UTC-aware. Comparing them raises TypeError. Add _ensure_aware() to normalize naive datetimes to UTC before comparison in both MessageHandler and MessagePublisher. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/handlers/message.py | 13 ++++++++++--- src/status_publisher/message.py | 17 +++++++++++++---- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/handlers/message.py b/src/handlers/message.py index 6e82d7c1..20a430e1 100644 --- a/src/handlers/message.py +++ b/src/handlers/message.py @@ -18,6 +18,13 @@ LOG = logging.getLogger(__name__) +def _ensure_aware(dt: datetime.datetime) -> datetime.datetime: + """Return *dt* with UTC tzinfo if it is naive, otherwise unchanged.""" + if dt.tzinfo is None: + return dt.replace(tzinfo=datetime.UTC) + return dt + + class MessageHandler: def __init__( self, @@ -53,10 +60,10 @@ async def __polling(self) -> None: if ( latest_message is not None and latest_message.messageId != self.last_message_id - and latest_message.message_time > self.last_message_ts + and _ensure_aware(latest_message.message_time) > self.last_message_ts ): self.last_message_id = latest_message.messageId - self.last_message_ts = latest_message.message_time + self.last_message_ts = _ensure_aware(latest_message.message_time) LOG.info( f"{latest_message.title} detected at {latest_message.message_time}" ) @@ -106,7 +113,7 @@ async def __get_all_alarm_messages(self) -> list[MessageEntity]: oldest_message = self.__get_oldest_message(all_messages) if ( oldest_message is not None - and oldest_message.message_time < self.last_message_ts + and _ensure_aware(oldest_message.message_time) < self.last_message_ts ): return all_messages except SaicLogoutException: diff --git a/src/status_publisher/message.py b/src/status_publisher/message.py index 549cb424..92ea6436 100644 --- a/src/status_publisher/message.py +++ b/src/status_publisher/message.py @@ -15,6 +15,14 @@ from vehicle_info import VehicleInfo LOG = logging.getLogger(__name__) +_EPOCH_MIN = datetime.min.replace(tzinfo=UTC) + + +def _ensure_aware(dt: datetime) -> datetime: + """Return *dt* with UTC tzinfo if it is naive, otherwise unchanged.""" + if dt.tzinfo is None: + return dt.replace(tzinfo=UTC) + return dt @dataclass(kw_only=True, frozen=True) @@ -29,15 +37,16 @@ def __init__( self, vin: VehicleInfo, publisher: Publisher, mqtt_vehicle_prefix: str ) -> None: super().__init__(vin, publisher, mqtt_vehicle_prefix) - self.__last_car_vehicle_message = datetime.min.replace(tzinfo=UTC) + self.__last_car_vehicle_message = _EPOCH_MIN @override def publish(self, message: MessageEntity) -> MessagePublisherProcessingResult: + msg_time = _ensure_aware(message.message_time) if ( - self.__last_car_vehicle_message == datetime.min.replace(tzinfo=UTC) - or message.message_time > self.__last_car_vehicle_message + self.__last_car_vehicle_message == _EPOCH_MIN + or msg_time > self.__last_car_vehicle_message ): - self.__last_car_vehicle_message = message.message_time + self.__last_car_vehicle_message = msg_time self._publish( topic=mqtt_topics.INFO_LAST_MESSAGE_TIME, value=self.__last_car_vehicle_message, From 9959a0e54757ee7ec487012ea9d6a8675a5301c6 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sat, 14 Mar 2026 10:13:33 +0100 Subject: [PATCH 5/5] fix: address review findings across event entities - Extract ensure_datetime_aware to utils.py, remove duplicates - Apply ensure_datetime_aware to max()/min() lambdas in message handler - Thread retain parameter through _transform_and_publish for consistency - Use LOG.exception consistently for event publish failures Co-Authored-By: Claude Opus 4.6 (1M context) --- src/handlers/message.py | 18 ++++++------------ src/handlers/vehicle_command.py | 3 +-- src/status_publisher/__init__.py | 5 ++++- src/status_publisher/message.py | 10 ++-------- src/utils.py | 7 +++++++ 5 files changed, 20 insertions(+), 23 deletions(-) diff --git a/src/handlers/message.py b/src/handlers/message.py index 20a430e1..ea43b6fb 100644 --- a/src/handlers/message.py +++ b/src/handlers/message.py @@ -6,6 +6,7 @@ from saic_ismart_client_ng.exceptions import SaicApiException, SaicLogoutException +from utils import ensure_datetime_aware from vehicle import RefreshMode if TYPE_CHECKING: @@ -18,13 +19,6 @@ LOG = logging.getLogger(__name__) -def _ensure_aware(dt: datetime.datetime) -> datetime.datetime: - """Return *dt* with UTC tzinfo if it is naive, otherwise unchanged.""" - if dt.tzinfo is None: - return dt.replace(tzinfo=datetime.UTC) - return dt - - class MessageHandler: def __init__( self, @@ -60,10 +54,10 @@ async def __polling(self) -> None: if ( latest_message is not None and latest_message.messageId != self.last_message_id - and _ensure_aware(latest_message.message_time) > self.last_message_ts + and ensure_datetime_aware(latest_message.message_time) > self.last_message_ts ): self.last_message_id = latest_message.messageId - self.last_message_ts = _ensure_aware(latest_message.message_time) + self.last_message_ts = ensure_datetime_aware(latest_message.message_time) LOG.info( f"{latest_message.title} detected at {latest_message.message_time}" ) @@ -113,7 +107,7 @@ async def __get_all_alarm_messages(self) -> list[MessageEntity]: oldest_message = self.__get_oldest_message(all_messages) if ( oldest_message is not None - and _ensure_aware(oldest_message.message_time) < self.last_message_ts + and ensure_datetime_aware(oldest_message.message_time) < self.last_message_ts ): return all_messages except SaicLogoutException: @@ -180,7 +174,7 @@ def __get_latest_message( ) -> MessageEntity | None: if len(vehicle_start_messages) == 0: return None - return max(vehicle_start_messages, key=lambda m: m.message_time) + return max(vehicle_start_messages, key=lambda m: ensure_datetime_aware(m.message_time)) @staticmethod def __get_oldest_message( @@ -188,4 +182,4 @@ def __get_oldest_message( ) -> MessageEntity | None: if len(vehicle_start_messages) == 0: return None - return min(vehicle_start_messages, key=lambda m: m.message_time) + return min(vehicle_start_messages, key=lambda m: ensure_datetime_aware(m.message_time)) diff --git a/src/handlers/vehicle_command.py b/src/handlers/vehicle_command.py index 04d617ea..f06596e8 100644 --- a/src/handlers/vehicle_command.py +++ b/src/handlers/vehicle_command.py @@ -86,10 +86,9 @@ def __report_command_failure( } self.publisher.publish_json(error_topic, event_payload, retain=False) except Exception: - LOG.warning( + LOG.exception( "Failed to publish command error event for command %s", command, - exc_info=True, ) async def handle_mqtt_command(self, *, topic: str, payload: str) -> None: diff --git a/src/status_publisher/__init__.py b/src/status_publisher/__init__.py index c39dce92..4fffbbae 100644 --- a/src/status_publisher/__init__.py +++ b/src/status_publisher/__init__.py @@ -53,12 +53,15 @@ def _transform_and_publish( validator: Callable[[T], bool] = lambda _: True, transform: Callable[[T], Publishable], no_prefix: bool = False, + retain: bool = True, ) -> tuple[bool, Publishable | None]: if value is None or not validator(value): return False, None actual_topic = topic if no_prefix else self.__get_topic(topic) transformed_value = transform(value) - published = self._publish_directly(topic=actual_topic, value=transformed_value) + published = self._publish_directly( + topic=actual_topic, value=transformed_value, retain=retain + ) return published, transformed_value def _publish_directly( diff --git a/src/status_publisher/message.py b/src/status_publisher/message.py index 92ea6436..0b28f7a9 100644 --- a/src/status_publisher/message.py +++ b/src/status_publisher/message.py @@ -9,6 +9,7 @@ import mqtt_topics from status_publisher import VehicleDataPublisher +from utils import ensure_datetime_aware if TYPE_CHECKING: from publisher.core import Publisher @@ -18,13 +19,6 @@ _EPOCH_MIN = datetime.min.replace(tzinfo=UTC) -def _ensure_aware(dt: datetime) -> datetime: - """Return *dt* with UTC tzinfo if it is naive, otherwise unchanged.""" - if dt.tzinfo is None: - return dt.replace(tzinfo=UTC) - return dt - - @dataclass(kw_only=True, frozen=True) class MessagePublisherProcessingResult: processed: bool @@ -41,7 +35,7 @@ def __init__( @override def publish(self, message: MessageEntity) -> MessagePublisherProcessingResult: - msg_time = _ensure_aware(message.message_time) + msg_time = ensure_datetime_aware(message.message_time) if ( self.__last_car_vehicle_message == _EPOCH_MIN or msg_time > self.__last_car_vehicle_message diff --git a/src/utils.py b/src/utils.py index 9b9fd004..781f2cab 100644 --- a/src/utils.py +++ b/src/utils.py @@ -58,6 +58,13 @@ def get_gateway_version() -> str: return os.environ.get("RELEASE_VERSION", "unknown") +def ensure_datetime_aware(dt: datetime) -> datetime: + """Return *dt* with UTC tzinfo if it is naive, otherwise unchanged.""" + if dt.tzinfo is None: + return dt.replace(tzinfo=UTC) + return dt + + def datetime_to_str(dt: datetime) -> str: return datetime.astimezone(dt, tz=UTC).isoformat()