Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions src/handlers/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -53,10 +54,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_datetime_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_datetime_aware(latest_message.message_time)
LOG.info(
f"{latest_message.title} detected at {latest_message.message_time}"
)
Expand Down Expand Up @@ -106,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 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:
Expand Down Expand Up @@ -173,12 +174,12 @@ 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(
vehicle_start_messages: list[MessageEntity],
) -> 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))
5 changes: 2 additions & 3 deletions src/handlers/vehicle_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,11 @@ 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(
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:
Expand Down
8 changes: 7 additions & 1 deletion src/integrations/home_assistant/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,14 +327,20 @@ 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",
["command_error"],
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")

Expand Down
3 changes: 3 additions & 0 deletions src/mqtt_topics.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,4 +182,7 @@
COMMAND = "command"
COMMAND_ERROR = COMMAND + "/error"

EVENTS = "events"
EVENTS_VEHICLE_MESSAGE = EVENTS + "/vehicleMessage"

VEHICLES = "vehicles"
7 changes: 6 additions & 1 deletion src/publisher/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 6 additions & 1 deletion src/publisher/log_publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 11 additions & 4 deletions src/publisher/mqtt_publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,19 +221,26 @@ 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:
return cast("bool", self.client.is_connected)

@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:
Expand Down
16 changes: 12 additions & 4 deletions src/status_publisher/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -50,15 +53,20 @@ 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(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)
Expand All @@ -73,7 +81,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))
Expand Down
39 changes: 35 additions & 4 deletions src/status_publisher/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,22 @@

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

import mqtt_topics
from status_publisher import VehicleDataPublisher
from utils import ensure_datetime_aware

if TYPE_CHECKING:
from publisher.core import Publisher
from vehicle_info import VehicleInfo

LOG = logging.getLogger(__name__)
_EPOCH_MIN = datetime.min.replace(tzinfo=UTC)


@dataclass(kw_only=True, frozen=True)
class MessagePublisherProcessingResult:
Expand All @@ -26,15 +31,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_datetime_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,
Expand Down Expand Up @@ -82,5 +88,30 @@ 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:
# 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,
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 "",
},
retain=False,
)
except Exception:
LOG.exception(
"Failed to publish vehicle message event for message %s (VIN: %s)",
message.messageId,
message.vin,
)
7 changes: 7 additions & 0 deletions src/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Empty file.
Loading
Loading