diff --git a/src/handlers/command/base.py b/src/handlers/command/base.py index 911114c..8b2bae4 100644 --- a/src/handlers/command/base.py +++ b/src/handlers/command/base.py @@ -1,6 +1,7 @@ from __future__ import annotations from abc import ABCMeta, abstractmethod +import dataclasses from typing import TYPE_CHECKING, Final, override from exceptions import MqttGatewayException @@ -14,6 +15,26 @@ from vehicle import VehicleState +@dataclasses.dataclass(kw_only=True, frozen=True) +class CommandProcessingResult: + force_refresh: bool + clear_command: bool + + +RESULT_DO_NOTHING: Final[CommandProcessingResult] = CommandProcessingResult( + force_refresh=False, clear_command=False +) +RESULT_REFRESH_AND_CLEAR: Final[CommandProcessingResult] = CommandProcessingResult( + force_refresh=True, clear_command=True +) +RESULT_CLEAR_ONLY: Final[CommandProcessingResult] = CommandProcessingResult( + force_refresh=False, clear_command=True +) +RESULT_REFRESH_ONLY: Final[CommandProcessingResult] = CommandProcessingResult( + force_refresh=True, clear_command=False +) + + class CommandHandlerBase(metaclass=ABCMeta): def __init__(self, saic_api: SaicApi, vehicle_state: VehicleState) -> None: self.__saic_api: Final[SaicApi] = saic_api @@ -29,7 +50,7 @@ def topic(cls) -> str: raise NotImplementedError @abstractmethod - async def handle(self, payload: str) -> bool: + async def handle(self, payload: str) -> CommandProcessingResult: raise NotImplementedError @property @@ -50,23 +71,32 @@ def publisher(self) -> Publisher: class MultiValuedCommandHandler[T](CommandHandlerBase, metaclass=ABCMeta): - async def should_refresh(self, _action_result: T) -> bool: - return True + @abstractmethod + async def _get_action_result(self, _action_result: T) -> CommandProcessingResult: + pass @abstractmethod def options(self) -> dict[str, Callable[[], Awaitable[T]]]: raise NotImplementedError + @property + def supports_empty_payload(self) -> bool: + return False + @override - async def handle(self, payload: str) -> bool: + async def handle(self, payload: str) -> CommandProcessingResult: normalized_payload = payload.strip().lower() + + if len(normalized_payload) == 0 and not self.supports_empty_payload: + return RESULT_DO_NOTHING + options = self.options() option_handler = options.get(normalized_payload) if option_handler is None: msg = f"Unsupported payload {payload} for command {self.name()}" raise MqttGatewayException(msg) response = await option_handler() - return await self.should_refresh(response) + return await self._get_action_result(response) class BooleanCommandHandler[T](CommandHandlerBase, metaclass=ABCMeta): @@ -78,12 +108,18 @@ async def handle_true(self) -> T: async def handle_false(self) -> T: raise NotImplementedError - async def should_refresh(self, _action_result: T) -> bool: - return True + @abstractmethod + async def _get_action_result(self, _action_result: T) -> CommandProcessingResult: + pass @override - async def handle(self, payload: str) -> bool: - match payload.strip().lower(): + async def handle(self, payload: str) -> CommandProcessingResult: + normalized_payload = payload.strip().lower() + + if len(normalized_payload) == 0: + return RESULT_DO_NOTHING + + match normalized_payload: case "true" | "1" | "on": response = await self.handle_true() case "false" | "0" | "off": @@ -91,7 +127,7 @@ async def handle(self, payload: str) -> bool: case _: msg = f"Unsupported payload {payload} for command {self.name()}" raise MqttGatewayException(msg) - return await self.should_refresh(response) + return await self._get_action_result(response) class PayloadConvertingCommandHandler[T](CommandHandlerBase, metaclass=ABCMeta): @@ -101,11 +137,18 @@ def convert_payload(payload: str) -> T: raise NotImplementedError @abstractmethod - async def handle_typed_payload(self, payload: T) -> bool: + async def handle_typed_payload(self, payload: T) -> CommandProcessingResult: raise NotImplementedError + @property + def supports_empty_payload(self) -> bool: + return False + @override - async def handle(self, payload: str) -> bool: + async def handle(self, payload: str) -> CommandProcessingResult: + if len(payload.strip()) == 0 and not self.supports_empty_payload: + return RESULT_DO_NOTHING + try: converted_payload = self.convert_payload(payload) except Exception as e: @@ -119,3 +162,9 @@ class IntCommandHandler(PayloadConvertingCommandHandler[int], metaclass=ABCMeta) @staticmethod def convert_payload(payload: str) -> int: return int(payload.strip().lower()) + + +class FloatCommandHandler(PayloadConvertingCommandHandler[float], metaclass=ABCMeta): + @staticmethod + def convert_payload(payload: str) -> float: + return float(payload.strip().lower()) diff --git a/src/handlers/command/climate/climate_back_window_heat.py b/src/handlers/command/climate/climate_back_window_heat.py index 1ce2b7e..6ce6fb0 100644 --- a/src/handlers/command/climate/climate_back_window_heat.py +++ b/src/handlers/command/climate/climate_back_window_heat.py @@ -3,7 +3,11 @@ import logging from typing import override -from handlers.command.base import BooleanCommandHandler +from handlers.command.base import ( + RESULT_REFRESH_AND_CLEAR, + BooleanCommandHandler, + CommandProcessingResult, +) import mqtt_topics LOG = logging.getLogger(__name__) @@ -24,3 +28,7 @@ async def handle_true(self) -> None: async def handle_false(self) -> None: LOG.info("Rear window heating will be switched off") await self.saic_api.control_rear_window_heat(self.vin, enable=False) + + @override + async def _get_action_result(self, _action_result: None) -> CommandProcessingResult: + return RESULT_REFRESH_AND_CLEAR diff --git a/src/handlers/command/climate/climate_front_window_heat.py b/src/handlers/command/climate/climate_front_window_heat.py index 9dc8e00..cf1df31 100644 --- a/src/handlers/command/climate/climate_front_window_heat.py +++ b/src/handlers/command/climate/climate_front_window_heat.py @@ -3,7 +3,11 @@ import logging from typing import override -from handlers.command.base import BooleanCommandHandler +from handlers.command.base import ( + RESULT_REFRESH_AND_CLEAR, + BooleanCommandHandler, + CommandProcessingResult, +) import mqtt_topics LOG = logging.getLogger(__name__) @@ -24,3 +28,7 @@ async def handle_true(self) -> None: async def handle_false(self) -> None: LOG.info("Front window heating will be switched off") await self.saic_api.stop_ac(self.vin) + + @override + async def _get_action_result(self, _action_result: None) -> CommandProcessingResult: + return RESULT_REFRESH_AND_CLEAR diff --git a/src/handlers/command/climate/climate_heated_seats_level.py b/src/handlers/command/climate/climate_heated_seats_level.py index 43ea7a8..70464f7 100644 --- a/src/handlers/command/climate/climate_heated_seats_level.py +++ b/src/handlers/command/climate/climate_heated_seats_level.py @@ -4,7 +4,11 @@ from typing import override from exceptions import MqttGatewayException -from handlers.command.base import IntCommandHandler +from handlers.command.base import ( + RESULT_REFRESH_AND_CLEAR, + CommandProcessingResult, + IntCommandHandler, +) import mqtt_topics LOG = logging.getLogger(__name__) @@ -17,7 +21,7 @@ def topic(cls) -> str: return mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_LEFT_LEVEL_SET @override - async def handle_typed_payload(self, level: int) -> bool: + async def handle_typed_payload(self, level: int) -> CommandProcessingResult: try: LOG.info("Setting heated seats front left level to %d", level) changed = self.vehicle_state.update_heated_seats_front_left_level(level) @@ -32,7 +36,7 @@ async def handle_typed_payload(self, level: int) -> bool: except Exception as e: msg = f"Error setting heated seats: {e}" raise MqttGatewayException(msg) from e - return True + return RESULT_REFRESH_AND_CLEAR class ClimateHeatedSeatsFrontRightLevelCommand(IntCommandHandler): @@ -42,7 +46,7 @@ def topic(cls) -> str: return mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_RIGHT_LEVEL_SET @override - async def handle_typed_payload(self, level: int) -> bool: + async def handle_typed_payload(self, level: int) -> CommandProcessingResult: try: LOG.info("Setting heated seats front right level to %d", level) changed = self.vehicle_state.update_heated_seats_front_right_level(level) @@ -57,4 +61,4 @@ async def handle_typed_payload(self, level: int) -> bool: except Exception as e: msg = f"Error setting heated seats: {e}" raise MqttGatewayException(msg) from e - return True + return RESULT_REFRESH_AND_CLEAR diff --git a/src/handlers/command/climate/climate_remote_climate_state.py b/src/handlers/command/climate/climate_remote_climate_state.py index a95669e..f760ffc 100644 --- a/src/handlers/command/climate/climate_remote_climate_state.py +++ b/src/handlers/command/climate/climate_remote_climate_state.py @@ -3,7 +3,11 @@ import logging from typing import TYPE_CHECKING, override -from handlers.command.base import MultiValuedCommandHandler +from handlers.command.base import ( + RESULT_REFRESH_AND_CLEAR, + CommandProcessingResult, + MultiValuedCommandHandler, +) import mqtt_topics if TYPE_CHECKING: @@ -27,6 +31,10 @@ def options(self) -> dict[str, Callable[[], Awaitable[None]]]: "front": self.__start_front_defrost, } + @override + async def _get_action_result(self, _action_result: None) -> CommandProcessingResult: + return RESULT_REFRESH_AND_CLEAR + async def __start_front_defrost(self) -> None: LOG.info("A/C will be set to front seats only") await self.saic_api.start_front_defrost(self.vin) diff --git a/src/handlers/command/climate/climate_remote_temperature.py b/src/handlers/command/climate/climate_remote_temperature.py index e355e2f..68fb34c 100644 --- a/src/handlers/command/climate/climate_remote_temperature.py +++ b/src/handlers/command/climate/climate_remote_temperature.py @@ -4,7 +4,11 @@ from typing import override from exceptions import MqttGatewayException -from handlers.command.base import IntCommandHandler +from handlers.command.base import ( + RESULT_REFRESH_ONLY, + CommandProcessingResult, + IntCommandHandler, +) import mqtt_topics LOG = logging.getLogger(__name__) @@ -17,7 +21,7 @@ def topic(cls) -> str: return mqtt_topics.CLIMATE_REMOTE_TEMPERATURE_SET @override - async def handle_typed_payload(self, temp: int) -> bool: + async def handle_typed_payload(self, temp: int) -> CommandProcessingResult: try: LOG.info("Setting remote climate target temperature to %d", temp) changed = self.vehicle_state.set_ac_temperature(temp) @@ -30,4 +34,4 @@ async def handle_typed_payload(self, temp: int) -> bool: except ValueError as e: msg = f"Error setting temperature target: {e}" raise MqttGatewayException(msg) from e - return True + return RESULT_REFRESH_ONLY diff --git a/src/handlers/command/doors/doors_boot.py b/src/handlers/command/doors/doors_boot.py index 514849c..52e7631 100644 --- a/src/handlers/command/doors/doors_boot.py +++ b/src/handlers/command/doors/doors_boot.py @@ -3,7 +3,11 @@ import logging from typing import override -from handlers.command.base import BooleanCommandHandler +from handlers.command.base import ( + RESULT_REFRESH_AND_CLEAR, + BooleanCommandHandler, + CommandProcessingResult, +) import mqtt_topics LOG = logging.getLogger(__name__) @@ -23,3 +27,7 @@ async def handle_true(self) -> None: async def handle_false(self) -> None: LOG.info(f"Vehicle {self.vin} boot will be unlocked") await self.saic_api.open_tailgate(self.vin) + + @override + async def _get_action_result(self, _action_result: None) -> CommandProcessingResult: + return RESULT_REFRESH_AND_CLEAR diff --git a/src/handlers/command/doors/doors_locked.py b/src/handlers/command/doors/doors_locked.py index 3ec37c5..a18813f 100644 --- a/src/handlers/command/doors/doors_locked.py +++ b/src/handlers/command/doors/doors_locked.py @@ -3,7 +3,11 @@ import logging from typing import override -from handlers.command.base import BooleanCommandHandler +from handlers.command.base import ( + RESULT_REFRESH_AND_CLEAR, + BooleanCommandHandler, + CommandProcessingResult, +) import mqtt_topics LOG = logging.getLogger(__name__) @@ -24,3 +28,7 @@ async def handle_true(self) -> None: async def handle_false(self) -> None: LOG.info(f"Vehicle {self.vin} will be unlocked") await self.saic_api.unlock_vehicle(self.vin) + + @override + async def _get_action_result(self, _action_result: None) -> CommandProcessingResult: + return RESULT_REFRESH_AND_CLEAR diff --git a/src/handlers/command/drivetrain/__init__.py b/src/handlers/command/drivetrain/__init__.py index 184a534..f23cec9 100644 --- a/src/handlers/command/drivetrain/__init__.py +++ b/src/handlers/command/drivetrain/__init__.py @@ -22,6 +22,9 @@ DrivetrainHVBatteryActiveCommand, ) from handlers.command.drivetrain.drivetrain_soc_target import DrivetrainSoCTargetCommand +from handlers.command.drivetrain.drivetrain_total_battery_capacity import ( + DrivetrainTotalBatteryCapacitySetCommand, +) if TYPE_CHECKING: from handlers.command import CommandHandlerBase @@ -33,6 +36,7 @@ DrivetrainChargingCommand, DrivetrainHVBatteryActiveCommand, DrivetrainSoCTargetCommand, + DrivetrainTotalBatteryCapacitySetCommand, DrivetrainChargingScheduleCommand, DrivetrainChargingCableLockCommand, ] diff --git a/src/handlers/command/drivetrain/drivetrain_battery_heating.py b/src/handlers/command/drivetrain/drivetrain_battery_heating.py index a9a0940..4a986e4 100644 --- a/src/handlers/command/drivetrain/drivetrain_battery_heating.py +++ b/src/handlers/command/drivetrain/drivetrain_battery_heating.py @@ -5,7 +5,11 @@ from saic_ismart_client_ng.api.vehicle_charging import ChrgPtcHeatResp -from handlers.command.base import BooleanCommandHandler +from handlers.command.base import ( + RESULT_REFRESH_AND_CLEAR, + BooleanCommandHandler, + CommandProcessingResult, +) import mqtt_topics LOG = logging.getLogger(__name__) @@ -28,7 +32,9 @@ async def handle_false(self) -> ChrgPtcHeatResp: return await self.saic_api.control_battery_heating(self.vin, enable=False) @override - async def should_refresh(self, response: ChrgPtcHeatResp | None) -> bool: + async def _get_action_result( + self, response: ChrgPtcHeatResp | None + ) -> CommandProcessingResult: if response is not None and response.ptcHeatResp is not None: decoded = response.heating_stop_reason self.publisher.publish_str( @@ -39,4 +45,4 @@ async def should_refresh(self, response: ChrgPtcHeatResp | None) -> bool: if decoded is None else decoded.name, ) - return True + return RESULT_REFRESH_AND_CLEAR diff --git a/src/handlers/command/drivetrain/drivetrain_battery_heating_schedule.py b/src/handlers/command/drivetrain/drivetrain_battery_heating_schedule.py index d6bc48b..087eac1 100644 --- a/src/handlers/command/drivetrain/drivetrain_battery_heating_schedule.py +++ b/src/handlers/command/drivetrain/drivetrain_battery_heating_schedule.py @@ -6,7 +6,11 @@ import logging from typing import TYPE_CHECKING, Any, override -from handlers.command.base import PayloadConvertingCommandHandler +from handlers.command.base import ( + RESULT_REFRESH_ONLY, + CommandProcessingResult, + PayloadConvertingCommandHandler, +) import mqtt_topics if TYPE_CHECKING: @@ -47,7 +51,7 @@ def convert_payload(payload: str) -> BatteryHeatingScheduleCommandPayload: @override async def handle_typed_payload( self, payload: BatteryHeatingScheduleCommandPayload - ) -> bool: + ) -> CommandProcessingResult: start_time = payload.start_time should_enable = payload.enable changed = self.vehicle_state.update_scheduled_battery_heating( @@ -64,4 +68,4 @@ async def handle_typed_payload( await self.saic_api.disable_schedule_battery_heating(self.vin) else: LOG.info("Battery heating schedule not changed") - return True + return RESULT_REFRESH_ONLY diff --git a/src/handlers/command/drivetrain/drivetrain_chargecurrent_limit.py b/src/handlers/command/drivetrain/drivetrain_chargecurrent_limit.py index b8bf8ca..2d6d0fe 100644 --- a/src/handlers/command/drivetrain/drivetrain_chargecurrent_limit.py +++ b/src/handlers/command/drivetrain/drivetrain_chargecurrent_limit.py @@ -8,7 +8,11 @@ TargetBatteryCode, ) -from handlers.command.base import PayloadConvertingCommandHandler +from handlers.command.base import ( + RESULT_REFRESH_ONLY, + CommandProcessingResult, + PayloadConvertingCommandHandler, +) import mqtt_topics LOG = logging.getLogger(__name__) @@ -30,7 +34,7 @@ def convert_payload(payload: str) -> ChargeCurrentLimitCode: @override async def handle_typed_payload( self, charge_current_limit: ChargeCurrentLimitCode - ) -> bool: + ) -> CommandProcessingResult: LOG.info("Setting charging current limit to %s", str(charge_current_limit)) await self.saic_api.set_target_battery_soc( self.vin, @@ -38,7 +42,7 @@ async def handle_typed_payload( charge_current_limit=charge_current_limit, ) self.vehicle_state.update_charge_current_limit(charge_current_limit) - return True + return RESULT_REFRESH_ONLY @property def __desired_target_soc(self) -> TargetBatteryCode: diff --git a/src/handlers/command/drivetrain/drivetrain_charging.py b/src/handlers/command/drivetrain/drivetrain_charging.py index 45b43a9..47d4243 100644 --- a/src/handlers/command/drivetrain/drivetrain_charging.py +++ b/src/handlers/command/drivetrain/drivetrain_charging.py @@ -3,7 +3,11 @@ import logging from typing import override -from handlers.command.base import BooleanCommandHandler +from handlers.command.base import ( + RESULT_REFRESH_AND_CLEAR, + BooleanCommandHandler, + CommandProcessingResult, +) from mqtt_topics import DRIVETRAIN_CHARGING_SET LOG = logging.getLogger(__name__) @@ -24,3 +28,7 @@ async def handle_true(self) -> None: async def handle_false(self) -> None: LOG.info("Charging will be stopped") await self.saic_api.control_charging(self.vin, stop_charging=True) + + @override + async def _get_action_result(self, _action_result: None) -> CommandProcessingResult: + return RESULT_REFRESH_AND_CLEAR diff --git a/src/handlers/command/drivetrain/drivetrain_charging_cable_lock.py b/src/handlers/command/drivetrain/drivetrain_charging_cable_lock.py index cd674fa..ebc7f9a 100644 --- a/src/handlers/command/drivetrain/drivetrain_charging_cable_lock.py +++ b/src/handlers/command/drivetrain/drivetrain_charging_cable_lock.py @@ -3,7 +3,11 @@ import logging from typing import override -from handlers.command.base import BooleanCommandHandler +from handlers.command.base import ( + RESULT_REFRESH_AND_CLEAR, + BooleanCommandHandler, + CommandProcessingResult, +) import mqtt_topics LOG = logging.getLogger(__name__) @@ -24,3 +28,7 @@ async def handle_true(self) -> None: async def handle_false(self) -> None: LOG.info(f"Vehicle {self.vin} charging cable will be unlocked") await self.saic_api.control_charging_port_lock(self.vin, unlock=True) + + @override + async def _get_action_result(self, _action_result: None) -> CommandProcessingResult: + return RESULT_REFRESH_AND_CLEAR diff --git a/src/handlers/command/drivetrain/drivetrain_charging_schedule.py b/src/handlers/command/drivetrain/drivetrain_charging_schedule.py index eba97b9..1412c92 100644 --- a/src/handlers/command/drivetrain/drivetrain_charging_schedule.py +++ b/src/handlers/command/drivetrain/drivetrain_charging_schedule.py @@ -8,7 +8,11 @@ from saic_ismart_client_ng.api.vehicle_charging import ScheduledChargingMode -from handlers.command.base import PayloadConvertingCommandHandler +from handlers.command.base import ( + RESULT_REFRESH_ONLY, + CommandProcessingResult, + PayloadConvertingCommandHandler, +) import mqtt_topics if TYPE_CHECKING: @@ -49,7 +53,7 @@ def convert_payload(payload: str) -> ChargingScheduleCommandPayload: @override async def handle_typed_payload( self, payload: ChargingScheduleCommandPayload - ) -> bool: + ) -> CommandProcessingResult: LOG.info("Setting charging schedule to %s", str(payload)) await self.saic_api.set_schedule_charging( self.vin, @@ -58,4 +62,4 @@ async def handle_typed_payload( mode=payload.mode, ) self.vehicle_state.update_scheduled_charging(payload.start_time, payload.mode) - return True + return RESULT_REFRESH_ONLY diff --git a/src/handlers/command/drivetrain/drivetrain_hv_battery_active.py b/src/handlers/command/drivetrain/drivetrain_hv_battery_active.py index 9290a30..03a41d1 100644 --- a/src/handlers/command/drivetrain/drivetrain_hv_battery_active.py +++ b/src/handlers/command/drivetrain/drivetrain_hv_battery_active.py @@ -3,7 +3,11 @@ import logging from typing import override -from handlers.command.base import BooleanCommandHandler +from handlers.command.base import ( + RESULT_REFRESH_AND_CLEAR, + BooleanCommandHandler, + CommandProcessingResult, +) from mqtt_topics import DRIVETRAIN_HV_BATTERY_ACTIVE_SET LOG = logging.getLogger(__name__) @@ -24,3 +28,7 @@ async def handle_true(self) -> None: async def handle_false(self) -> None: LOG.info("HV battery is now inactive") self.vehicle_state.hv_battery_active = False + + @override + async def _get_action_result(self, _action_result: None) -> CommandProcessingResult: + return RESULT_REFRESH_AND_CLEAR diff --git a/src/handlers/command/drivetrain/drivetrain_soc_target.py b/src/handlers/command/drivetrain/drivetrain_soc_target.py index e29da6d..b827130 100644 --- a/src/handlers/command/drivetrain/drivetrain_soc_target.py +++ b/src/handlers/command/drivetrain/drivetrain_soc_target.py @@ -5,7 +5,11 @@ from saic_ismart_client_ng.api.vehicle_charging import TargetBatteryCode -from handlers.command.base import PayloadConvertingCommandHandler +from handlers.command.base import ( + RESULT_REFRESH_ONLY, + CommandProcessingResult, + PayloadConvertingCommandHandler, +) import mqtt_topics LOG = logging.getLogger(__name__) @@ -26,10 +30,10 @@ def convert_payload(payload: str) -> TargetBatteryCode: @override async def handle_typed_payload( self, target_battery_code: TargetBatteryCode - ) -> bool: + ) -> CommandProcessingResult: LOG.info("Setting SoC target to %s", str(target_battery_code)) await self.saic_api.set_target_battery_soc( self.vin, target_soc=target_battery_code ) self.vehicle_state.update_target_soc(target_battery_code) - return True + return RESULT_REFRESH_ONLY diff --git a/src/handlers/command/drivetrain/drivetrain_total_battery_capacity.py b/src/handlers/command/drivetrain/drivetrain_total_battery_capacity.py new file mode 100644 index 0000000..e4ac585 --- /dev/null +++ b/src/handlers/command/drivetrain/drivetrain_total_battery_capacity.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import logging +from typing import override + +from handlers.command.base import ( + RESULT_DO_NOTHING, + CommandProcessingResult, + FloatCommandHandler, +) +import mqtt_topics + +LOG = logging.getLogger(__name__) + + +class DrivetrainTotalBatteryCapacitySetCommand(FloatCommandHandler): + @classmethod + @override + def topic(cls) -> str: + return mqtt_topics.DRIVETRAIN_TOTAL_BATTERY_CAPACITY_SET + + @override + async def handle_typed_payload(self, payload: float) -> CommandProcessingResult: + LOG.info("Setting Total Battery Capacity to %f", payload) + self.vehicle_state.update_battery_capacity(payload) + + # No need to force a refresh + return RESULT_DO_NOTHING diff --git a/src/handlers/command/gateway/refresh_mode.py b/src/handlers/command/gateway/refresh_mode.py index e9ee2f6..870cdfc 100644 --- a/src/handlers/command/gateway/refresh_mode.py +++ b/src/handlers/command/gateway/refresh_mode.py @@ -2,7 +2,11 @@ from typing import override -from handlers.command.base import PayloadConvertingCommandHandler +from handlers.command.base import ( + RESULT_DO_NOTHING, + CommandProcessingResult, + PayloadConvertingCommandHandler, +) import mqtt_topics from vehicle import RefreshMode @@ -20,8 +24,10 @@ def convert_payload(payload: str) -> RefreshMode: return RefreshMode.get(normalized_payload) @override - async def handle_typed_payload(self, refresh_mode: RefreshMode) -> bool: + async def handle_typed_payload( + self, refresh_mode: RefreshMode + ) -> CommandProcessingResult: self.vehicle_state.set_refresh_mode( refresh_mode, "MQTT direct set refresh mode command execution" ) - return False + return RESULT_DO_NOTHING diff --git a/src/handlers/command/gateway/refresh_period.py b/src/handlers/command/gateway/refresh_period.py index df425f1..5723b46 100644 --- a/src/handlers/command/gateway/refresh_period.py +++ b/src/handlers/command/gateway/refresh_period.py @@ -2,7 +2,11 @@ from typing import override -from handlers.command.base import IntCommandHandler +from handlers.command.base import ( + RESULT_DO_NOTHING, + CommandProcessingResult, + IntCommandHandler, +) import mqtt_topics @@ -13,9 +17,9 @@ def topic(cls) -> str: return mqtt_topics.REFRESH_PERIOD_ACTIVE_SET @override - async def handle_typed_payload(self, payload: int) -> bool: + async def handle_typed_payload(self, payload: int) -> CommandProcessingResult: self.vehicle_state.set_refresh_period_active(payload) - return False + return RESULT_DO_NOTHING class RefreshPeriodInactiveCommand(IntCommandHandler): @@ -25,9 +29,9 @@ def topic(cls) -> str: return mqtt_topics.REFRESH_PERIOD_INACTIVE_SET @override - async def handle_typed_payload(self, payload: int) -> bool: + async def handle_typed_payload(self, payload: int) -> CommandProcessingResult: self.vehicle_state.set_refresh_period_inactive(payload) - return False + return RESULT_DO_NOTHING class RefreshPeriodInactiveGraceCommand(IntCommandHandler): @@ -37,9 +41,9 @@ def topic(cls) -> str: return mqtt_topics.REFRESH_PERIOD_INACTIVE_GRACE_SET @override - async def handle_typed_payload(self, payload: int) -> bool: + async def handle_typed_payload(self, payload: int) -> CommandProcessingResult: self.vehicle_state.set_refresh_period_inactive_grace(payload) - return False + return RESULT_DO_NOTHING class RefreshPeriodAfterShutdownCommand(IntCommandHandler): @@ -49,6 +53,6 @@ def topic(cls) -> str: return mqtt_topics.REFRESH_PERIOD_AFTER_SHUTDOWN_SET @override - async def handle_typed_payload(self, payload: int) -> bool: + async def handle_typed_payload(self, payload: int) -> CommandProcessingResult: self.vehicle_state.set_refresh_period_after_shutdown(payload) - return False + return RESULT_DO_NOTHING diff --git a/src/handlers/command/location/location_find_my_car.py b/src/handlers/command/location/location_find_my_car.py index 3a0f8e6..3325f3d 100644 --- a/src/handlers/command/location/location_find_my_car.py +++ b/src/handlers/command/location/location_find_my_car.py @@ -3,7 +3,11 @@ import logging from typing import TYPE_CHECKING, override -from handlers.command.base import MultiValuedCommandHandler +from handlers.command.base import ( + RESULT_REFRESH_AND_CLEAR, + CommandProcessingResult, + MultiValuedCommandHandler, +) import mqtt_topics if TYPE_CHECKING: @@ -27,6 +31,10 @@ def options(self) -> dict[str, Callable[[], Awaitable[None]]]: "stop": self.__handle_stop, } + @override + async def _get_action_result(self, _action_result: None) -> CommandProcessingResult: + return RESULT_REFRESH_AND_CLEAR + async def __handle_activate(self) -> None: LOG.info( f"Activating 'find my car' with horn and lights for vehicle {self.vin}" diff --git a/src/handlers/vehicle.py b/src/handlers/vehicle.py index c38f70f..66628ee 100644 --- a/src/handlers/vehicle.py +++ b/src/handlers/vehicle.py @@ -126,6 +126,9 @@ async def handle_vehicle(self) -> None: while True: if self.__should_complete_configuration(start_time): + LOG.info( + "Waiting to complete configuration vehicle %s", self.vin_info.vin + ) self.vehicle_state.configure_missing() if self.__should_poll(): diff --git a/src/handlers/vehicle_command.py b/src/handlers/vehicle_command.py index 1ca7d14..0ac6bc6 100644 --- a/src/handlers/vehicle_command.py +++ b/src/handlers/vehicle_command.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass import logging from typing import TYPE_CHECKING, Final @@ -24,6 +25,14 @@ LOG = logging.getLogger(__name__) +@dataclass(frozen=True, kw_only=True) +class _MqttCommandTopic: + command: str + command_no_global: str + command_no_vin: str + response_no_global: str + + class VehicleCommandHandler: def __init__( self, @@ -49,15 +58,15 @@ def publisher(self) -> Publisher: return self.vehicle_state.publisher async def handle_mqtt_command(self, *, topic: str, payload: str) -> None: - topic, result_topic = self.__get_command_topics(topic) - handler = self.__command_handlers.get(topic) + analyzed_topic = self.__get_command_topics(topic) + handler = self.__command_handlers.get(analyzed_topic.command_no_vin) if not handler: - msg = f"No handler found for command topic {topic}" - self.publisher.publish_str(result_topic, msg) + msg = f"No handler found for command topic {analyzed_topic.command_no_vin}" + self.publisher.publish_str(analyzed_topic.response_no_global, msg) LOG.error(msg) else: await self.__execute_mqtt_command_handler( - handler=handler, payload=payload, topic=topic, result_topic=result_topic + handler=handler, payload=payload, analyzed_topic=analyzed_topic ) async def __execute_mqtt_command_handler( @@ -65,16 +74,21 @@ async def __execute_mqtt_command_handler( *, handler: CommandHandlerBase, payload: str, - topic: str, - result_topic: str, + analyzed_topic: _MqttCommandTopic, ) -> None: + topic = analyzed_topic.command_no_vin + topic_no_global = analyzed_topic.command_no_global + result_topic = analyzed_topic.response_no_global + try: - should_force_refresh = await handler.handle(payload) + execution_result = await handler.handle(payload) self.publisher.publish_str(result_topic, "Success") - if should_force_refresh: + 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: + self.publisher.clear_topic(topic_no_global) except MqttGatewayException as e: self.publisher.publish_str(result_topic, f"Failed: {e.message}") LOG.exception(e.message, exc_info=e) @@ -91,7 +105,7 @@ async def __execute_mqtt_command_handler( "handle_mqtt_command failed with an unexpected exception", exc_info=se ) - def __get_command_topics(self, topic: str) -> tuple[str, str]: + def __get_command_topics(self, topic: str) -> _MqttCommandTopic: global_topic_removed = topic.removeprefix(self.global_mqtt_topic).removeprefix( "/" ) @@ -103,4 +117,9 @@ def __get_command_topics(self, topic: str) -> tuple[str, str]: + "/" + mqtt_topics.RESULT_SUFFIX ) - return set_topic, result_topic + return _MqttCommandTopic( + command=topic, + command_no_global=global_topic_removed, + command_no_vin=set_topic, + response_no_global=result_topic, + ) diff --git a/src/integrations/home_assistant/discovery.py b/src/integrations/home_assistant/discovery.py index 6ed8ad6..f02e96b 100644 --- a/src/integrations/home_assistant/discovery.py +++ b/src/integrations/home_assistant/discovery.py @@ -153,6 +153,19 @@ def __publish_ha_discovery_messages_real(self) -> None: unit_of_measurement="kWh", icon="mdi:battery-high", ) + self._publish_number( + mqtt_topics.DRIVETRAIN_TOTAL_BATTERY_CAPACITY, + "Total Battery Capacity", + entity_category="diagnostic", + device_class="ENERGY_STORAGE", + state_class="measurement", + unit_of_measurement="kWh", + icon="mdi:battery-high", + mode="box", + min_value=0.0, + step=0.001, + ) + self._publish_sensor( mqtt_topics.DRIVETRAIN_LAST_CHARGE_ENDING_POWER, "Last Charge SoC kWh", diff --git a/src/mqtt_gateway.py b/src/mqtt_gateway.py index b8c3fa7..844a5ae 100644 --- a/src/mqtt_gateway.py +++ b/src/mqtt_gateway.py @@ -88,9 +88,11 @@ async def run(self) -> None: for vin_info in vin_list.vinList: await self.setup_vehicle(alarm_switches, vin_info) + message_handler = MessageHandler( gateway=self, relogin_handler=self.__relogin_handler, saicapi=self.saic_api ) + self.__scheduler.add_job( func=message_handler.check_for_new_messages, trigger="interval", @@ -100,6 +102,10 @@ async def run(self) -> None: max_instances=1, ) + # We defer this later in the process so that we can properly configure the gateway and each car via MQTT + LOG.info("Enabling MQTT command handling") + self.publisher.enable_commands() + LOG.info("Starting scheduler") self.__scheduler.start() diff --git a/src/mqtt_topics.py b/src/mqtt_topics.py index 38234ca..b4e4873 100644 --- a/src/mqtt_topics.py +++ b/src/mqtt_topics.py @@ -87,6 +87,9 @@ DRIVETRAIN_SOC_KWH = DRIVETRAIN + "/soc_kwh" DRIVETRAIN_LAST_CHARGE_ENDING_POWER = DRIVETRAIN + "/lastChargeEndingPower" DRIVETRAIN_TOTAL_BATTERY_CAPACITY = DRIVETRAIN + "/totalBatteryCapacity" +DRIVETRAIN_TOTAL_BATTERY_CAPACITY_SET = ( + DRIVETRAIN_TOTAL_BATTERY_CAPACITY + "/" + SET_SUFFIX +) DRIVETRAIN_VOLTAGE = DRIVETRAIN + "/voltage" DRIVETRAIN_CHARGING_CABLE_LOCK = DRIVETRAIN + "/chargingCableLock" DRIVETRAIN_CHARGING_CABLE_LOCK_SET = DRIVETRAIN_CHARGING_CABLE_LOCK + "/" + SET_SUFFIX diff --git a/src/publisher/core.py b/src/publisher/core.py index 1c53bee..b0ffa66 100644 --- a/src/publisher/core.py +++ b/src/publisher/core.py @@ -45,6 +45,10 @@ def __init__(self, config: Configuration) -> None: async def connect(self) -> None: pass + @abstractmethod + def enable_commands(self) -> None: + pass + @abstractmethod def is_connected(self) -> bool: raise NotImplementedError @@ -71,6 +75,10 @@ def publish_bool(self, key: str, value: bool, no_prefix: bool = False) -> None: def publish_float(self, key: str, value: float, no_prefix: bool = False) -> None: raise NotImplementedError + @abstractmethod + def clear_topic(self, key: str, no_prefix: bool = False) -> None: + raise NotImplementedError + def get_mqtt_account_prefix(self) -> str: return self.__remove_special_mqtt_characters( f"{self.__topic_root}/{self.configuration.saic_user}" diff --git a/src/publisher/log_publisher.py b/src/publisher/log_publisher.py index 3299ebc..efb6b24 100644 --- a/src/publisher/log_publisher.py +++ b/src/publisher/log_publisher.py @@ -18,6 +18,10 @@ async def connect(self) -> None: def is_connected(self) -> bool: return True + @override + def enable_commands(self) -> None: + pass + @override def publish_json( self, key: str, data: dict[str, Any], no_prefix: bool = False @@ -41,5 +45,9 @@ def publish_bool(self, key: str, value: bool, no_prefix: bool = False) -> None: def publish_float(self, key: str, value: float, no_prefix: bool = False) -> None: self.internal_publish(key, value) + @override + def clear_topic(self, key: str, no_prefix: bool = False) -> None: + self.internal_publish(key, None) + def internal_publish(self, key: str, value: Any) -> None: LOG.debug(f"{key}: {value}") diff --git a/src/publisher/mqtt_publisher.py b/src/publisher/mqtt_publisher.py index 82c7988..8d80bb6 100644 --- a/src/publisher/mqtt_publisher.py +++ b/src/publisher/mqtt_publisher.py @@ -26,6 +26,7 @@ def __init__(self, configuration: Configuration) -> None: self.vin_by_charge_state_topic: dict[str, str] = {} self.last_charge_state_by_vin: dict[str, str] = {} self.vin_by_charger_connected_topic: dict[str, str] = {} + self.first_connection = True mqtt_client = gmqtt.Client( client_id=str(self.publisher_id), @@ -72,40 +73,9 @@ def __on_connect( ) -> None: if rc == gmqtt.constants.CONNACK_ACCEPTED: LOG.info("Connected to MQTT broker") - mqtt_account_prefix = self.get_mqtt_account_prefix() - self.client.subscribe( - f"{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/+/+/{mqtt_topics.SET_SUFFIX}" - ) - self.client.subscribe( - f"{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/+/+/+/{mqtt_topics.SET_SUFFIX}" - ) - self.client.subscribe( - f"{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/{mqtt_topics.REFRESH_MODE}/{mqtt_topics.SET_SUFFIX}" - ) - self.client.subscribe( - f"{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/{mqtt_topics.REFRESH_PERIOD}/+/{mqtt_topics.SET_SUFFIX}" - ) - for ( - charging_station - ) in self.configuration.charging_stations_by_vin.values(): - LOG.debug( - f"Subscribing to MQTT topic {charging_station.charge_state_topic}" - ) - self.vin_by_charge_state_topic[charging_station.charge_state_topic] = ( - charging_station.vin - ) - self.client.subscribe(charging_station.charge_state_topic) - if charging_station.connected_topic: - LOG.debug( - f"Subscribing to MQTT topic {charging_station.connected_topic}" - ) - self.vin_by_charger_connected_topic[ - charging_station.connected_topic - ] = charging_station.vin - self.client.subscribe(charging_station.connected_topic) - if self.configuration.ha_discovery_enabled: - # enable dynamic discovery pushing in case ha reconnects - self.client.subscribe(self.configuration.ha_lwt_topic) + if not self.first_connection: + self.enable_commands() + self.first_connection = False self.keepalive() else: if rc == gmqtt.constants.CONNACK_REFUSED_BAD_USERNAME_PASSWORD: @@ -121,6 +91,42 @@ def __on_connect( msg = f"Unable to connect to MQTT broker. Return code: {rc}" raise SystemExit(msg) + @override + def enable_commands(self) -> None: + LOG.info("Subscribing to MQTT command topics") + mqtt_account_prefix = self.get_mqtt_account_prefix() + self.client.subscribe( + f"{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/+/+/{mqtt_topics.SET_SUFFIX}" + ) + self.client.subscribe( + f"{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/+/+/+/{mqtt_topics.SET_SUFFIX}" + ) + self.client.subscribe( + f"{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/{mqtt_topics.REFRESH_MODE}/{mqtt_topics.SET_SUFFIX}" + ) + self.client.subscribe( + f"{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/{mqtt_topics.REFRESH_PERIOD}/+/{mqtt_topics.SET_SUFFIX}" + ) + for charging_station in self.configuration.charging_stations_by_vin.values(): + LOG.debug( + f"Subscribing to MQTT topic {charging_station.charge_state_topic}" + ) + self.vin_by_charge_state_topic[charging_station.charge_state_topic] = ( + charging_station.vin + ) + self.client.subscribe(charging_station.charge_state_topic) + if charging_station.connected_topic: + LOG.debug( + f"Subscribing to MQTT topic {charging_station.connected_topic}" + ) + self.vin_by_charger_connected_topic[ + charging_station.connected_topic + ] = charging_station.vin + self.client.subscribe(charging_station.connected_topic) + if self.configuration.ha_discovery_enabled: + # enable dynamic discovery pushing in case ha reconnects + self.client.subscribe(self.configuration.ha_lwt_topic) + async def __on_message( self, _client: Any, topic: str, payload: Any, _qos: Any, _properties: Any ) -> None: @@ -198,6 +204,10 @@ def publish_bool(self, key: str, value: bool, no_prefix: bool = False) -> None: def publish_float(self, key: str, value: float, no_prefix: bool = False) -> None: self.__publish(topic=self.get_topic(key, no_prefix), payload=value) + @override + def clear_topic(self, key: str, no_prefix: bool = False) -> None: + self.__publish(topic=self.get_topic(key, no_prefix), payload=None) + def get_vin_from_topic(self, topic: str) -> str: global_topic_removed = topic[len(self.configuration.mqtt_topic) + 1 :] elements = global_topic_removed.split("/") diff --git a/src/vehicle.py b/src/vehicle.py index 215e56b..8d62512 100644 --- a/src/vehicle.py +++ b/src/vehicle.py @@ -647,6 +647,9 @@ def set_ac_temperature(self, temp: int) -> bool: def get_ac_temperature_idx(self) -> int: return self.vehicle.get_ac_temperature_idx(self.get_remote_ac_temperature()) + def update_battery_capacity(self, new_capacity: float) -> None: + self.vehicle.custom_battery_capacity = new_capacity + @property def is_remote_ac_running(self) -> bool: return self.__remote_ac_running diff --git a/src/vehicle_info.py b/src/vehicle_info.py index 2d9f171..191d2ba 100644 --- a/src/vehicle_info.py +++ b/src/vehicle_info.py @@ -33,6 +33,14 @@ def __init__( ) self.__custom_battery_capacity: float | None = custom_battery_capacity + @property + def custom_battery_capacity(self) -> float | None: + return self.__custom_battery_capacity + + @custom_battery_capacity.setter + def custom_battery_capacity(self, value: float) -> None: + self.__custom_battery_capacity = value + @staticmethod def __properties_from_configuration( configuration: list[VehicleModelConfiguration],