Skip to content

Commit 7a6e77b

Browse files
authored
Merge pull request #402 from nanomad/fix/ha-unknown-after-restart
fix: republish command entity states after broker/HA restart
2 parents 4b2f552 + 9d5e253 commit 7a6e77b

7 files changed

Lines changed: 143 additions & 5 deletions

File tree

‎src/handlers/vehicle.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,13 @@ def publish_ha_discovery_messages(self, *, force: bool = False) -> None:
264264
LOG.info(
265265
f"Sending HA discovery messages for {self.vin_info.vin} (Force: {force})"
266266
)
267-
self.__ha_discovery.publish_ha_discovery_messages(force=force)
267+
published = self.__ha_discovery.publish_ha_discovery_messages(force=force)
268+
if published:
269+
self.vehicle_state.republish_command_states()
270+
271+
def reset_ha_discovery(self) -> None:
272+
if self.__ha_discovery is not None:
273+
self.__ha_discovery.published = False
268274

269275
async def update_vehicle_status(
270276
self,

‎src/integrations/home_assistant/discovery.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,21 +53,22 @@ def __init__(
5353
)
5454
self.published = False
5555

56-
def publish_ha_discovery_messages(self, *, force: bool = False) -> None:
56+
def publish_ha_discovery_messages(self, *, force: bool = False) -> bool:
5757
if not self.__vehicle_state.is_complete():
5858
LOG.warning(
5959
"Skipping Home Assistant discovery messages as vehicle state is not yet complete"
6060
)
61-
return
61+
return False
6262

6363
if self.published and not force:
6464
LOG.debug(
6565
"Skipping Home Assistant discovery messages as it was already published"
6666
)
67-
return
67+
return False
6868

6969
self.__publish_ha_discovery_messages_real()
7070
self.published = True
71+
return True
7172

7273
def __publish_ha_discovery_messages_real(self) -> None:
7374
LOG.debug("Publishing Home Assistant discovery messages")

‎src/mqtt_gateway.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,13 @@ async def on_charging_detected(self, vin: str) -> None:
207207
else:
208208
LOG.debug(f"Charging detected for unknown vin {vin}")
209209

210+
@override
211+
def on_mqtt_reconnected(self) -> None:
212+
LOG.info("MQTT reconnected, resetting HA discovery for all vehicles")
213+
for vin, vh in self.vehicle_handlers.items():
214+
LOG.debug(f"Resetting HA discovery for vehicle {vin}")
215+
vh.reset_ha_discovery()
216+
210217
@override
211218
async def on_mqtt_global_command_received(
212219
self, *, topic: str, payload: str

‎src/publisher/core.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ async def on_mqtt_global_command_received(
3030
) -> None:
3131
raise NotImplementedError("Should have implemented this")
3232

33+
def on_mqtt_reconnected(self) -> None: # noqa: B027
34+
"""Reset state when the MQTT client reconnects after a connection loss.
35+
36+
This is intentionally synchronous because it is called from gmqtt's
37+
synchronous on_connect callback. It is also intentionally not abstract
38+
so that implementations can opt in without being forced to override.
39+
"""
40+
3341

3442
class Publisher(ABC):
3543
def __init__(self, config: Configuration) -> None:

‎src/publisher/mqtt_publisher.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ def __on_connect(
7979
LOG.info("Connected to MQTT broker")
8080
if not self.first_connection:
8181
self.enable_commands()
82+
if self.command_listener is not None:
83+
self.command_listener.on_mqtt_reconnected()
8284
self.first_connection = False
8385
self.keepalive()
8486
else:

‎src/vehicle.py‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,58 @@ def configure_missing(self) -> None:
451451
f"initial gateway startup from an invalid state {self.refresh_mode}",
452452
)
453453

454+
def republish_command_states(self) -> None:
455+
"""Unconditionally publish all command entity values to MQTT.
456+
457+
This bypasses change detection so that retained messages are restored
458+
after a broker or Home Assistant restart.
459+
"""
460+
if self.refresh_period_active != -1:
461+
self.publisher.publish_int(
462+
self.get_topic(mqtt_topics.REFRESH_PERIOD_ACTIVE),
463+
self.refresh_period_active,
464+
)
465+
if self.refresh_period_inactive != -1:
466+
self.publisher.publish_int(
467+
self.get_topic(mqtt_topics.REFRESH_PERIOD_INACTIVE),
468+
self.refresh_period_inactive,
469+
)
470+
if self.refresh_period_after_shutdown != -1:
471+
self.publisher.publish_int(
472+
self.get_topic(mqtt_topics.REFRESH_PERIOD_AFTER_SHUTDOWN),
473+
self.refresh_period_after_shutdown,
474+
)
475+
if self.refresh_period_inactive_grace != -1:
476+
self.publisher.publish_int(
477+
self.get_topic(mqtt_topics.REFRESH_PERIOD_INACTIVE_GRACE),
478+
self.refresh_period_inactive_grace,
479+
)
480+
if self.refresh_period_charging > 0:
481+
self.publisher.publish_int(
482+
self.get_topic(mqtt_topics.REFRESH_PERIOD_CHARGING),
483+
self.refresh_period_charging,
484+
)
485+
if self.refresh_mode is not None:
486+
self.publisher.publish_str(
487+
self.get_topic(mqtt_topics.REFRESH_MODE),
488+
self.refresh_mode.value,
489+
)
490+
if self.__remote_ac_temp is not None:
491+
self.publisher.publish_int(
492+
self.get_topic(mqtt_topics.CLIMATE_REMOTE_TEMPERATURE),
493+
self.__remote_ac_temp,
494+
)
495+
if self.target_soc is not None:
496+
self.publisher.publish_int(
497+
self.get_topic(mqtt_topics.DRIVETRAIN_SOC_TARGET),
498+
self.target_soc.percentage,
499+
)
500+
if self.charge_current_limit is not None:
501+
self.publisher.publish_str(
502+
self.get_topic(mqtt_topics.DRIVETRAIN_CHARGECURRENT_LIMIT),
503+
self.charge_current_limit.limit,
504+
)
505+
454506
def handle_charge_status(
455507
self, charge_info_resp: ChrgMgmtDataResp
456508
) -> ChrgMgmtDataRespProcessingResult:

‎tests/test_vehicle_state.py‎

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,14 @@
66
from apscheduler.schedulers.blocking import BlockingScheduler
77
import pytest
88
from saic_ismart_client_ng.api.vehicle.schema import VinInfo
9+
from saic_ismart_client_ng.api.vehicle_charging import (
10+
ChargeCurrentLimitCode,
11+
TargetBatteryCode,
12+
)
913

1014
from configuration import Configuration
1115
import mqtt_topics
12-
from vehicle import VehicleState
16+
from vehicle import RefreshMode, VehicleState
1317
from vehicle_info import VehicleInfo
1418

1519
from .common_mocks import (
@@ -124,6 +128,64 @@ def test_handle_charge_status_with_phev_ignore_values(self) -> None:
124128
assert result.scheduled_charging is None
125129
assert self.get_topic(mqtt_topics.DRIVETRAIN_SOC_TARGET) not in self.publisher.map
126130

131+
def test_republish_command_states_after_configure_missing(self) -> None:
132+
self.vehicle_state.configure_missing()
133+
self.publisher.map.clear()
134+
135+
self.vehicle_state.republish_command_states()
136+
137+
self.assert_mqtt_topic(
138+
self.get_topic(mqtt_topics.REFRESH_PERIOD_ACTIVE), 30
139+
)
140+
self.assert_mqtt_topic(
141+
self.get_topic(mqtt_topics.REFRESH_PERIOD_INACTIVE), 86400
142+
)
143+
self.assert_mqtt_topic(
144+
self.get_topic(mqtt_topics.REFRESH_PERIOD_AFTER_SHUTDOWN), 120
145+
)
146+
self.assert_mqtt_topic(
147+
self.get_topic(mqtt_topics.REFRESH_PERIOD_INACTIVE_GRACE), 600
148+
)
149+
self.assert_mqtt_topic(
150+
self.get_topic(mqtt_topics.CLIMATE_REMOTE_TEMPERATURE), 22
151+
)
152+
self.assert_mqtt_topic(
153+
self.get_topic(mqtt_topics.REFRESH_MODE), RefreshMode.PERIODIC.value
154+
)
155+
156+
def test_republish_command_states_skips_unset_values(self) -> None:
157+
self.vehicle_state.republish_command_states()
158+
159+
# Refresh periods are -1 and optional values are None, so they should not be published
160+
assert self.get_topic(mqtt_topics.REFRESH_PERIOD_ACTIVE) not in self.publisher.map
161+
assert self.get_topic(mqtt_topics.REFRESH_PERIOD_INACTIVE) not in self.publisher.map
162+
assert self.get_topic(mqtt_topics.REFRESH_PERIOD_AFTER_SHUTDOWN) not in self.publisher.map
163+
assert self.get_topic(mqtt_topics.REFRESH_PERIOD_INACTIVE_GRACE) not in self.publisher.map
164+
assert self.get_topic(mqtt_topics.DRIVETRAIN_SOC_TARGET) not in self.publisher.map
165+
assert self.get_topic(mqtt_topics.DRIVETRAIN_CHARGECURRENT_LIMIT) not in self.publisher.map
166+
assert self.get_topic(mqtt_topics.CLIMATE_REMOTE_TEMPERATURE) not in self.publisher.map
167+
# refresh_mode defaults to RefreshMode.OFF (never None), so it IS always published
168+
self.assert_mqtt_topic(
169+
self.get_topic(mqtt_topics.REFRESH_MODE), RefreshMode.OFF.value
170+
)
171+
172+
def test_republish_command_states_includes_api_values(self) -> None:
173+
self.vehicle_state.configure_missing()
174+
self.vehicle_state.update_target_soc(TargetBatteryCode.P_80)
175+
self.vehicle_state.update_charge_current_limit(ChargeCurrentLimitCode.C_MAX)
176+
self.publisher.map.clear()
177+
178+
self.vehicle_state.republish_command_states()
179+
180+
self.assert_mqtt_topic(
181+
self.get_topic(mqtt_topics.DRIVETRAIN_SOC_TARGET),
182+
TargetBatteryCode.P_80.percentage,
183+
)
184+
self.assert_mqtt_topic(
185+
self.get_topic(mqtt_topics.DRIVETRAIN_CHARGECURRENT_LIMIT),
186+
ChargeCurrentLimitCode.C_MAX.limit,
187+
)
188+
127189
@staticmethod
128190
def get_topic(sub_topic: str) -> str:
129191
return f"/vehicles/{VIN}/{sub_topic}"

0 commit comments

Comments
 (0)