Skip to content

Commit d88f995

Browse files
nanomadclaude
andauthored
feat: add HA Event entity for command errors (SAIC-iSmart-API#432)
* feat: add Home Assistant Event entity for command errors Closes SAIC-iSmart-API#272. When a command to the vehicle fails, a JSON event is now published to the command/error topic so Home Assistant users can build automations around command failures. Changes: - Add _publish_event() to HA discovery base for MQTT Event entities - Register a "Command error" diagnostic event entity in discovery - Publish error events from all failure paths in VehicleCommandHandler - Wrap error event publishing in try-except to prevent masking original errors - Cover the "no handler found" path with error events too Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: consolidate command failure reporting into single helper Replace repeated publish_str + LOG + publish_command_error calls across all except blocks with a single __report_command_failure method. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: harden __report_command_failure against publish errors - Log the original error before attempting any MQTT publish - Wrap both publish_str and publish_json in try-except - Make exc optional so "no handler found" uses LOG.error (no traceback) - Use safe "unexpected error" detail for the catch-all exception path Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add tests for command error event publishing Cover all error paths in VehicleCommandHandler: - Success path (no error event) - No handler found (error event without traceback) - MqttGatewayException, SaicApiException, unexpected Exception - SaicLogoutException: relogin success/failure, retry failure - Resilience: publish_str/publish_json failures don't propagate - Event payload structure and topic verification Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: rewrite tests to pass mypy strict checks Restructure tests to avoid accessing private name-mangled attributes and asserting on typed Publisher methods. Drive exceptions through saic_api mocks instead of patching internal command handlers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8edf815 commit d88f995

5 files changed

Lines changed: 344 additions & 19 deletions

File tree

src/handlers/vehicle_command.py

Lines changed: 63 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from dataclasses import dataclass
44
import logging
5-
from typing import TYPE_CHECKING, Final
5+
from typing import TYPE_CHECKING, Any, Final
66

77
from saic_ismart_client_ng.exceptions import SaicApiException, SaicLogoutException
88

@@ -57,13 +57,51 @@ def __init__(
5757
def publisher(self) -> Publisher:
5858
return self.vehicle_state.publisher
5959

60+
def __report_command_failure(
61+
self,
62+
*,
63+
command: str,
64+
result_topic: str,
65+
detail: str,
66+
exc: Exception | None = None,
67+
) -> None:
68+
if exc is not None:
69+
LOG.exception("Command %s failed: %s", command, detail, exc_info=exc)
70+
else:
71+
LOG.error("Command %s failed: %s", command, detail)
72+
try:
73+
self.publisher.publish_str(result_topic, f"Failed: {detail}")
74+
except Exception:
75+
LOG.warning(
76+
"Failed to publish failure result for command %s",
77+
command,
78+
exc_info=True,
79+
)
80+
try:
81+
error_topic = self.vehicle_state.get_topic(mqtt_topics.COMMAND_ERROR)
82+
event_payload: dict[str, Any] = {
83+
"event_type": "command_error",
84+
"command": command,
85+
"detail": detail,
86+
}
87+
self.publisher.publish_json(error_topic, event_payload)
88+
except Exception:
89+
LOG.warning(
90+
"Failed to publish command error event for command %s",
91+
command,
92+
exc_info=True,
93+
)
94+
6095
async def handle_mqtt_command(self, *, topic: str, payload: str) -> None:
6196
analyzed_topic = self.__get_command_topics(topic)
6297
handler = self.__command_handlers.get(analyzed_topic.command_no_vin)
6398
if not handler:
6499
msg = f"No handler found for command topic {analyzed_topic.command_no_vin}"
65-
self.publisher.publish_str(analyzed_topic.response_no_global, msg)
66-
LOG.error(msg)
100+
self.__report_command_failure(
101+
command=analyzed_topic.command_no_vin,
102+
result_topic=analyzed_topic.response_no_global,
103+
detail=msg,
104+
)
67105
else:
68106
await self.__execute_mqtt_command_handler(
69107
handler=handler, payload=payload, analyzed_topic=analyzed_topic
@@ -90,19 +128,22 @@ async def __execute_mqtt_command_handler(
90128
if execution_result.clear_command:
91129
self.publisher.clear_topic(topic_no_global)
92130
except MqttGatewayException as e:
93-
self.publisher.publish_str(result_topic, f"Failed: {e.message}")
94-
LOG.exception(e.message, exc_info=e)
131+
self.__report_command_failure(
132+
command=topic, result_topic=result_topic, detail=e.message, exc=e
133+
)
95134
except SaicLogoutException:
96135
LOG.warning(
97136
"API Client was logged out, attempting immediate relogin and retry"
98137
)
99138
try:
100139
await self.relogin_handler.force_login()
101140
except Exception as login_err:
102-
self.publisher.publish_str(
103-
result_topic, f"Failed: relogin failed ({login_err})"
141+
self.__report_command_failure(
142+
command=topic,
143+
result_topic=result_topic,
144+
detail=f"relogin failed ({login_err})",
145+
exc=login_err,
104146
)
105-
LOG.error("Immediate relogin failed", exc_info=login_err)
106147
return
107148
try:
108149
execution_result = await handler.handle(payload)
@@ -115,19 +156,22 @@ async def __execute_mqtt_command_handler(
115156
if execution_result.clear_command:
116157
self.publisher.clear_topic(topic_no_global)
117158
except Exception as retry_err:
118-
self.publisher.publish_str(
119-
result_topic, f"Failed: {retry_err}"
120-
)
121-
LOG.error(
122-
"Command retry after relogin failed", exc_info=retry_err
159+
self.__report_command_failure(
160+
command=topic,
161+
result_topic=result_topic,
162+
detail=str(retry_err),
163+
exc=retry_err,
123164
)
124165
except SaicApiException as se:
125-
self.publisher.publish_str(result_topic, f"Failed: {se.message}")
126-
LOG.exception(se.message, exc_info=se)
127-
except Exception as se:
128-
self.publisher.publish_str(result_topic, "Failed unexpectedly")
129-
LOG.exception(
130-
"handle_mqtt_command failed with an unexpected exception", exc_info=se
166+
self.__report_command_failure(
167+
command=topic, result_topic=result_topic, detail=se.message, exc=se
168+
)
169+
except Exception as e:
170+
self.__report_command_failure(
171+
command=topic,
172+
result_topic=result_topic,
173+
detail="unexpected error",
174+
exc=e,
131175
)
132176

133177
def __get_command_topics(self, topic: str) -> _MqttCommandTopic:

src/integrations/home_assistant/base.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,34 @@ def _publish_lock(
230230
"lock", name, payload, custom_availability
231231
)
232232

233+
def _publish_event(
234+
self,
235+
topic: str,
236+
name: str,
237+
event_types: list[str],
238+
*,
239+
enabled: bool = True,
240+
entity_category: str | None = None,
241+
device_class: str | None = None,
242+
icon: str | None = None,
243+
custom_availability: HaCustomAvailabilityConfig | None = None,
244+
) -> str:
245+
payload: dict[str, Any] = {
246+
"state_topic": self._get_state_topic(topic),
247+
"event_types": event_types,
248+
"enabled_by_default": enabled,
249+
}
250+
if entity_category is not None:
251+
payload["entity_category"] = entity_category
252+
if device_class is not None:
253+
payload["device_class"] = device_class
254+
if icon is not None:
255+
payload["icon"] = icon
256+
257+
return self._publish_ha_discovery_message(
258+
"event", name, payload, custom_availability
259+
)
260+
233261
def _publish_sensor(
234262
self,
235263
topic: str,

src/integrations/home_assistant/discovery.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,15 @@ def __publish_ha_discovery_messages_real(self) -> None:
327327
)
328328
self.__publish_lights_sensors()
329329

330+
# Command error event
331+
self._publish_event(
332+
mqtt_topics.COMMAND_ERROR,
333+
"Command error",
334+
["command_error"],
335+
entity_category="diagnostic",
336+
icon="mdi:alert-circle",
337+
)
338+
330339
LOG.debug("Completed publishing Home Assistant discovery messages")
331340

332341
def __publish_drivetrain_charging_sensors(self) -> None:

src/mqtt_topics.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,4 +179,7 @@
179179
TYRES_REAR_LEFT_PRESSURE = TYRES + "/rearLeftPressure"
180180
TYRES_REAR_RIGHT_PRESSURE = TYRES + "/rearRightPressure"
181181

182+
COMMAND = "command"
183+
COMMAND_ERROR = COMMAND + "/error"
184+
182185
VEHICLES = "vehicles"

0 commit comments

Comments
 (0)