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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Change Log

## Unreleased

### Fixed

* Persist user-set HA gateway entities across gateway restarts by retaining
their `/set` commands on the MQTT broker (refresh mode, all four refresh
periods, and total battery capacity). On reconnect the existing command-
dispatch path replays the retained value before `configure_missing()` would
apply config defaults. A retained one-shot refresh mode (`force`,
`charging_detection`) is dropped on replay so a single-shot poll does not
fire on every restart.

Note: on first upgrade only entities you change *after* the upgrade become
persistent. Existing retained STATE values on the broker are not converted
into retained `/set` commands.

## 0.11.0

### Added
Expand Down
38 changes: 34 additions & 4 deletions src/handlers/command/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,28 @@ def __init__(self, saic_api: SaicApi, vehicle_state: VehicleState) -> None:
def name(cls) -> str:
return cls.__name__

@classmethod
def is_replayable_when_retained(cls) -> bool:
"""Whether the dispatcher may invoke this handler with retained=True.

Default False: action-bearing commands (charging start/stop, locks,
climate, FORCE refresh, etc.) would re-fire on every gateway restart
if their `/set` topic was retained on the broker, so the dispatcher
drops the replay before the handler runs. Override to True only on
idempotent value-bearing handlers whose HA discovery payload also
declares `retain: true`.
"""
return False

@classmethod
@abstractmethod
def topic(cls) -> str:
raise NotImplementedError

@abstractmethod
async def handle(self, payload: str) -> CommandProcessingResult:
async def handle(
self, payload: str, *, retained: bool = False
) -> CommandProcessingResult:
raise NotImplementedError

@property
Expand Down Expand Up @@ -84,7 +99,12 @@ def supports_empty_payload(self) -> bool:
return False

@override
async def handle(self, payload: str) -> CommandProcessingResult:
async def handle(
self,
payload: str,
*,
retained: bool = False,
) -> CommandProcessingResult:
normalized_payload = payload.strip().lower()

if len(normalized_payload) == 0 and not self.supports_empty_payload:
Expand Down Expand Up @@ -113,7 +133,12 @@ async def _get_action_result(self, _action_result: T) -> CommandProcessingResult
pass

@override
async def handle(self, payload: str) -> CommandProcessingResult:
async def handle(
self,
payload: str,
*,
retained: bool = False,
) -> CommandProcessingResult:
normalized_payload = payload.strip().lower()

if len(normalized_payload) == 0:
Expand Down Expand Up @@ -145,7 +170,12 @@ def supports_empty_payload(self) -> bool:
return False

@override
async def handle(self, payload: str) -> CommandProcessingResult:
async def handle(
self,
payload: str,
*,
retained: bool = False,
) -> CommandProcessingResult:
if len(payload.strip()) == 0 and not self.supports_empty_payload:
return RESULT_DO_NOTHING

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@


class DrivetrainTotalBatteryCapacitySetCommand(FloatCommandHandler):
@classmethod
@override
def is_replayable_when_retained(cls) -> bool:
return True

@classmethod
@override
def topic(cls) -> str:
Expand Down
33 changes: 32 additions & 1 deletion src/handlers/command/gateway/refresh_mode.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
from __future__ import annotations

import logging
from typing import override

from exceptions import MqttGatewayException
from handlers.command.base import (
RESULT_DO_NOTHING,
CommandProcessingResult,
PayloadConvertingCommandHandler,
)
import mqtt_topics
from vehicle import RefreshMode
from vehicle import ONE_SHOT_REFRESH_MODES, RefreshMode

LOG = logging.getLogger(__name__)


class RefreshModeCommand(PayloadConvertingCommandHandler[RefreshMode]):
@classmethod
@override
def is_replayable_when_retained(cls) -> bool:
# OFF / PERIODIC are persistent user choices; one-shots dropped in handle().
return True

@classmethod
@override
def topic(cls) -> str:
Expand All @@ -23,6 +33,27 @@ def convert_payload(payload: str) -> RefreshMode:
normalized_payload = payload.strip().lower()
return RefreshMode.get(normalized_payload)

@override
async def handle(
self, payload: str, *, retained: bool = False
) -> CommandProcessingResult:
if len(payload.strip()) == 0 and not self.supports_empty_payload:
return RESULT_DO_NOTHING
try:
refresh_mode = self.convert_payload(payload)
except Exception as e:
msg = f"Error converting payload {payload} for command {self.name()}"
raise MqttGatewayException(msg) from e
if retained and refresh_mode in ONE_SHOT_REFRESH_MODES:
# Retained one-shot modes would re-fire on every gateway restart.
LOG.info(
"Dropping retained one-shot refresh mode %s for VIN %s",
refresh_mode.value,
self.vin,
)
return RESULT_DO_NOTHING
return await self.handle_typed_payload(refresh_mode)

@override
async def handle_typed_payload(
self, refresh_mode: RefreshMode
Expand Down
20 changes: 20 additions & 0 deletions src/handlers/command/gateway/refresh_period.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@


class RefreshPeriodActiveCommand(IntCommandHandler):
@classmethod
@override
def is_replayable_when_retained(cls) -> bool:
return True

@classmethod
@override
def topic(cls) -> str:
Expand All @@ -23,6 +28,11 @@ async def handle_typed_payload(self, payload: int) -> CommandProcessingResult:


class RefreshPeriodInactiveCommand(IntCommandHandler):
@classmethod
@override
def is_replayable_when_retained(cls) -> bool:
return True

@classmethod
@override
def topic(cls) -> str:
Expand All @@ -35,6 +45,11 @@ async def handle_typed_payload(self, payload: int) -> CommandProcessingResult:


class RefreshPeriodInactiveGraceCommand(IntCommandHandler):
@classmethod
@override
def is_replayable_when_retained(cls) -> bool:
return True

@classmethod
@override
def topic(cls) -> str:
Expand All @@ -47,6 +62,11 @@ async def handle_typed_payload(self, payload: int) -> CommandProcessingResult:


class RefreshPeriodAfterShutdownCommand(IntCommandHandler):
@classmethod
@override
def is_replayable_when_retained(cls) -> bool:
return True

@classmethod
@override
def topic(cls) -> str:
Expand Down
16 changes: 9 additions & 7 deletions src/handlers/vehicle.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,10 +241,9 @@ def __should_poll(self) -> bool:
)

def __should_complete_configuration(self, start_time: datetime.datetime) -> bool:
return (
not self.vehicle_state.is_complete()
and datetime.datetime.now(tz=datetime.UTC) > start_time + datetime.timedelta(seconds=10)
)
return not self.vehicle_state.is_complete() and datetime.datetime.now(
tz=datetime.UTC
) > start_time + datetime.timedelta(seconds=10)

def __refresh_openwb(
self,
Expand Down Expand Up @@ -334,8 +333,12 @@ async def update_scheduled_battery_heating_status(
)
return scheduled_battery_heating_status

async def handle_mqtt_command(self, *, topic: str, payload: str) -> None:
await self.__command_handler.handle_mqtt_command(topic=topic, payload=payload)
async def handle_mqtt_command(
self, *, topic: str, payload: str, retained: bool = False
) -> None:
await self.__command_handler.handle_mqtt_command(
topic=topic, payload=payload, retained=retained
)

def __setup_ha_discovery(
self, vehicle_state: VehicleState, vin_info: VehicleInfo, config: Configuration
Expand All @@ -344,7 +347,6 @@ def __setup_ha_discovery(
return HomeAssistantDiscovery(vehicle_state, vin_info, config)
return None


def handle_charging_station_energy_imported(
self, imported_energy_wh: float
) -> None:
Expand Down
31 changes: 25 additions & 6 deletions src/handlers/vehicle_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@ def __report_command_failure(
command,
)

async def handle_mqtt_command(self, *, topic: str, payload: str) -> None:
async def handle_mqtt_command(
self, *, topic: str, payload: str, retained: bool = False
) -> None:
analyzed_topic = self.__get_command_topics(topic)
handler = self.__command_handlers.get(analyzed_topic.command_no_vin)
if not handler:
Expand All @@ -103,7 +105,10 @@ async def handle_mqtt_command(self, *, topic: str, payload: str) -> None:
)
else:
await self.__execute_mqtt_command_handler(
handler=handler, payload=payload, analyzed_topic=analyzed_topic
handler=handler,
payload=payload,
analyzed_topic=analyzed_topic,
retained=retained,
)

async def __execute_mqtt_command_handler(
Expand All @@ -112,19 +117,33 @@ async def __execute_mqtt_command_handler(
handler: CommandHandlerBase,
payload: str,
analyzed_topic: _MqttCommandTopic,
retained: bool,
) -> None:
topic = analyzed_topic.command_no_vin
topic_no_global = analyzed_topic.command_no_global
result_topic = analyzed_topic.response_no_global

if retained and not handler.is_replayable_when_retained():
# A retained `/set` for an action-bearing command would re-fire the
# action on every gateway restart. Drop it before invoking the
# handler. Only handlers that explicitly opt in via
# ``replayable_when_retained = True`` see retained replays.
LOG.warning(
"Dropping retained replay for non-replayable command %s on %s; "
"this command should not have been published with retain=true",
handler.name(),
topic,
)
return

try:
execution_result = await handler.handle(payload)
execution_result = await handler.handle(payload, retained=retained)
self.publisher.publish_str(result_topic, "Success")
if execution_result.force_refresh:
self.vehicle_state.set_refresh_mode(
RefreshMode.FORCE, f"after command execution on topic {topic}"
)
if execution_result.clear_command:
if execution_result.clear_command and not retained:
self.publisher.clear_topic(topic_no_global)
except MqttGatewayException as e:
self.__report_command_failure(
Expand All @@ -145,14 +164,14 @@ async def __execute_mqtt_command_handler(
)
return
try:
execution_result = await handler.handle(payload)
execution_result = await handler.handle(payload, retained=retained)
self.publisher.publish_str(result_topic, "Success")
if execution_result.force_refresh:
self.vehicle_state.set_refresh_mode(
RefreshMode.FORCE,
f"after command execution on topic {topic}",
)
if execution_result.clear_command:
if execution_result.clear_command and not retained:
self.publisher.clear_topic(topic_no_global)
except Exception as retry_err:
self.__report_command_failure(
Expand Down
4 changes: 3 additions & 1 deletion src/integrations/home_assistant/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,16 @@ def _publish_select(
enabled: bool = True,
value_template: str = "{{ value }}",
command_template: str = "{{ value }}",
retain: bool = False,
icon: str | None = None,
custom_availability: HaCustomAvailabilityConfig | None = None,
) -> str:
payload = {
payload: dict[str, Any] = {
"state_topic": self._get_state_topic(topic),
"command_topic": self._get_command_topic(topic),
"value_template": value_template,
"command_template": command_template,
"retain": str(retain).lower(),
"options": options,
"enabled_by_default": enabled,
}
Expand Down
6 changes: 6 additions & 0 deletions src/integrations/home_assistant/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ def __publish_ha_discovery_messages_real(self) -> None:
mode="box",
min_value=0.0,
step=0.001,
retain=True,
)

self._publish_sensor(
Expand Down Expand Up @@ -640,6 +641,7 @@ def __publish_gateway_sensors(self) -> None:
value_template="{{ value }}",
command_template="{{ value }}",
icon="mdi:refresh",
retain=True,
custom_availability=self.__system_availability_config,
)
self._publish_number(
Expand All @@ -651,6 +653,7 @@ def __publish_gateway_sensors(self) -> None:
min_value=30,
max_value=60 * 60,
step=1,
retain=True,
custom_availability=self.__system_availability_config,
)
self._publish_number(
Expand All @@ -662,6 +665,7 @@ def __publish_gateway_sensors(self) -> None:
min_value=1 * 60 * 60,
max_value=5 * 24 * 60 * 60,
step=1,
retain=True,
custom_availability=self.__system_availability_config,
)
self._publish_number(
Expand All @@ -673,6 +677,7 @@ def __publish_gateway_sensors(self) -> None:
min_value=30,
max_value=12 * 60 * 60,
step=1,
retain=True,
custom_availability=self.__system_availability_config,
)
self._publish_number(
Expand All @@ -684,6 +689,7 @@ def __publish_gateway_sensors(self) -> None:
min_value=30,
max_value=12 * 60 * 60,
step=1,
retain=True,
custom_availability=self.__system_availability_config,
)
self._publish_sensor(
Expand Down
Loading