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
73 changes: 61 additions & 12 deletions src/handlers/command/base.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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):
Expand All @@ -78,20 +108,26 @@ 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":
response = await self.handle_false()
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):
Expand All @@ -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:
Expand All @@ -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())
10 changes: 9 additions & 1 deletion src/handlers/command/climate/climate_back_window_heat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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
10 changes: 9 additions & 1 deletion src/handlers/command/climate/climate_front_window_heat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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
14 changes: 9 additions & 5 deletions src/handlers/command/climate/climate_heated_seats_level.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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)
Expand All @@ -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):
Expand All @@ -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)
Expand All @@ -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
10 changes: 9 additions & 1 deletion src/handlers/command/climate/climate_remote_climate_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand Down
10 changes: 7 additions & 3 deletions src/handlers/command/climate/climate_remote_temperature.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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)
Expand All @@ -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
10 changes: 9 additions & 1 deletion src/handlers/command/doors/doors_boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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
10 changes: 9 additions & 1 deletion src/handlers/command/doors/doors_locked.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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
4 changes: 4 additions & 0 deletions src/handlers/command/drivetrain/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,6 +36,7 @@
DrivetrainChargingCommand,
DrivetrainHVBatteryActiveCommand,
DrivetrainSoCTargetCommand,
DrivetrainTotalBatteryCapacitySetCommand,
DrivetrainChargingScheduleCommand,
DrivetrainChargingCableLockCommand,
]
12 changes: 9 additions & 3 deletions src/handlers/command/drivetrain/drivetrain_battery_heating.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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(
Expand All @@ -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
Loading