Skip to content

Commit a5c1b89

Browse files
committed
Move each command into its own class
1 parent 51debf1 commit a5c1b89

31 files changed

+1105
-495
lines changed

src/handlers/command.py

Lines changed: 0 additions & 493 deletions
This file was deleted.

src/handlers/command/__init__.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from __future__ import annotations
2+
3+
from handlers.command.base import CommandHandlerBase
4+
from handlers.command.climate import ALL_COMMAND_HANDLERS as CLIMATE_COMMAND_HANDLERS
5+
from handlers.command.doors import ALL_COMMAND_HANDLERS as DOORS_COMMAND_HANDLERS
6+
from handlers.command.drivetrain import (
7+
ALL_COMMAND_HANDLERS as DRIVETRAIN_COMMAND_HANDLERS,
8+
)
9+
from handlers.command.gateway import ALL_COMMAND_HANDLERS as GATEWAY_COMMAND_HANDLERS
10+
from handlers.command.location import ALL_COMMAND_HANDLERS as LOCATION_COMMAND_HANDLERS
11+
12+
ALL_COMMAND_HANDLERS: list[type[CommandHandlerBase]] = [
13+
*CLIMATE_COMMAND_HANDLERS,
14+
*DOORS_COMMAND_HANDLERS,
15+
*DRIVETRAIN_COMMAND_HANDLERS,
16+
*GATEWAY_COMMAND_HANDLERS,
17+
*LOCATION_COMMAND_HANDLERS,
18+
]
19+
20+
__all__ = ["ALL_COMMAND_HANDLERS", "CommandHandlerBase"]

src/handlers/command/base.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
from __future__ import annotations
2+
3+
from abc import ABCMeta, abstractmethod
4+
from typing import TYPE_CHECKING, Final, override
5+
6+
from exceptions import MqttGatewayException
7+
8+
if TYPE_CHECKING:
9+
from collections.abc import Awaitable, Callable
10+
11+
from saic_ismart_client_ng import SaicApi
12+
13+
from publisher.core import Publisher
14+
from vehicle import VehicleState
15+
16+
17+
class CommandHandlerBase(metaclass=ABCMeta):
18+
def __init__(self, saic_api: SaicApi, vehicle_state: VehicleState) -> None:
19+
self.__saic_api: Final[SaicApi] = saic_api
20+
self.__vehicle_state: Final[VehicleState] = vehicle_state
21+
22+
@classmethod
23+
def name(cls) -> str:
24+
return cls.__name__
25+
26+
@classmethod
27+
@abstractmethod
28+
def topic(cls) -> str:
29+
raise NotImplementedError
30+
31+
@abstractmethod
32+
async def handle(self, payload: str) -> bool:
33+
raise NotImplementedError
34+
35+
@property
36+
def saic_api(self) -> SaicApi:
37+
return self.__saic_api
38+
39+
@property
40+
def vehicle_state(self) -> VehicleState:
41+
return self.__vehicle_state
42+
43+
@property
44+
def vin(self) -> str:
45+
return self.__vehicle_state.vin
46+
47+
@property
48+
def publisher(self) -> Publisher:
49+
return self.__vehicle_state.publisher
50+
51+
52+
class MultiValuedCommandHandler[T](CommandHandlerBase, metaclass=ABCMeta):
53+
async def should_refresh(self, action_result: T) -> bool:
54+
return True
55+
56+
@abstractmethod
57+
def options(self) -> dict[str, Callable[[], Awaitable[T]]]:
58+
raise NotImplementedError
59+
60+
@override
61+
async def handle(self, payload: str) -> bool:
62+
normalized_payload = payload.strip().lower()
63+
options = self.options()
64+
option_handler = options.get(normalized_payload)
65+
if option_handler is None:
66+
msg = f"Unsupported payload {payload} for command {self.name()}"
67+
raise MqttGatewayException(msg)
68+
response = await option_handler()
69+
return await self.should_refresh(response)
70+
71+
72+
class BooleanCommandHandler[T](CommandHandlerBase, metaclass=ABCMeta):
73+
@abstractmethod
74+
async def handle_true(self) -> T:
75+
raise NotImplementedError
76+
77+
@abstractmethod
78+
async def handle_false(self) -> T:
79+
raise NotImplementedError
80+
81+
async def should_refresh(self, action_result: T) -> bool:
82+
return True
83+
84+
@override
85+
async def handle(self, payload: str) -> bool:
86+
match payload.strip().lower():
87+
case "true" | "1" | "on":
88+
response = await self.handle_true()
89+
case "false" | "0" | "off":
90+
response = await self.handle_false()
91+
case _:
92+
msg = f"Unsupported payload {payload} for command {self.name()}"
93+
raise MqttGatewayException(msg)
94+
return await self.should_refresh(response)
95+
96+
97+
class PayloadConvertingCommandHandler[T](CommandHandlerBase, metaclass=ABCMeta):
98+
@staticmethod
99+
@abstractmethod
100+
def convert_payload(payload: str) -> T:
101+
raise NotImplementedError
102+
103+
@abstractmethod
104+
async def handle_typed_payload(self, payload: T) -> bool:
105+
raise NotImplementedError
106+
107+
@override
108+
async def handle(self, payload: str) -> bool:
109+
try:
110+
converted_payload = self.convert_payload(payload)
111+
except Exception as e:
112+
msg = f"Error converting payload {payload} for command {self.name()}"
113+
raise MqttGatewayException(msg) from e
114+
115+
return await self.handle_typed_payload(converted_payload)
116+
117+
118+
class IntCommandHandler(PayloadConvertingCommandHandler[int], metaclass=ABCMeta):
119+
@staticmethod
120+
def convert_payload(payload: str) -> int:
121+
return int(payload.strip().lower())
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
from handlers.command.climate.climate_back_window_heat import (
6+
ClimateBackWindowHeatCommand,
7+
)
8+
from handlers.command.climate.climate_front_window_heat import (
9+
ClimateFrontWindowHeatCommand,
10+
)
11+
from handlers.command.climate.climate_heated_seats_level import (
12+
ClimateHeatedSeatsFrontLeftLevelCommand,
13+
ClimateHeatedSeatsFrontRightLevelCommand,
14+
)
15+
from handlers.command.climate.climate_remote_climate_state import (
16+
ClimateRemoteClimateStateCommand,
17+
)
18+
from handlers.command.climate.climate_remote_temperature import (
19+
ClimateRemoteTemperatureCommand,
20+
)
21+
22+
if TYPE_CHECKING:
23+
from handlers.command import CommandHandlerBase
24+
25+
ALL_COMMAND_HANDLERS: list[type[CommandHandlerBase]] = [
26+
ClimateBackWindowHeatCommand,
27+
ClimateFrontWindowHeatCommand,
28+
ClimateRemoteClimateStateCommand,
29+
ClimateRemoteTemperatureCommand,
30+
ClimateHeatedSeatsFrontLeftLevelCommand,
31+
ClimateHeatedSeatsFrontRightLevelCommand,
32+
]
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from typing import override
5+
6+
from handlers.command.base import BooleanCommandHandler
7+
import mqtt_topics
8+
9+
LOG = logging.getLogger(__name__)
10+
11+
12+
class ClimateBackWindowHeatCommand(BooleanCommandHandler[None]):
13+
@classmethod
14+
@override
15+
def topic(cls) -> str:
16+
return mqtt_topics.CLIMATE_BACK_WINDOW_HEAT_SET
17+
18+
@override
19+
async def handle_true(self) -> None:
20+
LOG.info("Rear window heating will be switched on")
21+
await self.saic_api.control_rear_window_heat(self.vin, enable=True)
22+
23+
@override
24+
async def handle_false(self) -> None:
25+
LOG.info("Rear window heating will be switched off")
26+
await self.saic_api.control_rear_window_heat(self.vin, enable=False)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from typing import override
5+
6+
from handlers.command.base import BooleanCommandHandler
7+
import mqtt_topics
8+
9+
LOG = logging.getLogger(__name__)
10+
11+
12+
class ClimateFrontWindowHeatCommand(BooleanCommandHandler[None]):
13+
@classmethod
14+
@override
15+
def topic(cls) -> str:
16+
return mqtt_topics.CLIMATE_FRONT_WINDOW_HEAT_SET
17+
18+
@override
19+
async def handle_true(self) -> None:
20+
LOG.info("Front window heating will be switched on")
21+
await self.saic_api.start_front_defrost(self.vin)
22+
23+
@override
24+
async def handle_false(self) -> None:
25+
LOG.info("Front window heating will be switched off")
26+
await self.saic_api.stop_ac(self.vin)
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from typing import override
5+
6+
from exceptions import MqttGatewayException
7+
from handlers.command.base import IntCommandHandler
8+
import mqtt_topics
9+
10+
LOG = logging.getLogger(__name__)
11+
12+
13+
class ClimateHeatedSeatsFrontLeftLevelCommand(IntCommandHandler):
14+
@classmethod
15+
@override
16+
def topic(cls) -> str:
17+
return mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_LEFT_LEVEL_SET
18+
19+
@override
20+
async def handle_typed_payload(self, level: int) -> bool:
21+
try:
22+
LOG.info("Setting heated seats front left level to %d", level)
23+
changed = self.vehicle_state.update_heated_seats_front_left_level(level)
24+
if changed:
25+
await self.saic_api.control_heated_seats(
26+
self.vin,
27+
left_side_level=self.vehicle_state.remote_heated_seats_front_left_level,
28+
right_side_level=self.vehicle_state.remote_heated_seats_front_right_level,
29+
)
30+
else:
31+
LOG.info("Heated seats front left level not changed")
32+
except Exception as e:
33+
msg = f"Error setting heated seats: {e}"
34+
raise MqttGatewayException(msg) from e
35+
return True
36+
37+
38+
class ClimateHeatedSeatsFrontRightLevelCommand(IntCommandHandler):
39+
@classmethod
40+
@override
41+
def topic(cls) -> str:
42+
return mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_RIGHT_LEVEL_SET
43+
44+
@override
45+
async def handle_typed_payload(self, level: int) -> bool:
46+
try:
47+
LOG.info("Setting heated seats front right level to %d", level)
48+
changed = self.vehicle_state.update_heated_seats_front_right_level(level)
49+
if changed:
50+
await self.saic_api.control_heated_seats(
51+
self.vin,
52+
left_side_level=self.vehicle_state.remote_heated_seats_front_left_level,
53+
right_side_level=self.vehicle_state.remote_heated_seats_front_right_level,
54+
)
55+
else:
56+
LOG.info("Heated seats front right level not changed")
57+
except Exception as e:
58+
msg = f"Error setting heated seats: {e}"
59+
raise MqttGatewayException(msg) from e
60+
return True
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from typing import TYPE_CHECKING, override
5+
6+
from handlers.command.base import MultiValuedCommandHandler
7+
import mqtt_topics
8+
9+
if TYPE_CHECKING:
10+
from collections.abc import Awaitable, Callable
11+
12+
LOG = logging.getLogger(__name__)
13+
14+
15+
class ClimateRemoteClimateStateCommand(MultiValuedCommandHandler[None]):
16+
@classmethod
17+
@override
18+
def topic(cls) -> str:
19+
return mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE_SET
20+
21+
@override
22+
def options(self) -> dict[str, Callable[[], Awaitable[None]]]:
23+
return {
24+
"off": self.__stop_ac,
25+
"blowingonly": self.__start_ac_blowing,
26+
"on": self.__start_ac,
27+
"front": self.__start_front_defrost,
28+
}
29+
30+
async def __start_front_defrost(self) -> None:
31+
LOG.info("A/C will be set to front seats only")
32+
await self.saic_api.start_front_defrost(self.vin)
33+
34+
async def __start_ac(self) -> None:
35+
LOG.info("A/C will be switched on")
36+
await self.saic_api.start_ac(
37+
self.vin,
38+
temperature_idx=self.vehicle_state.get_ac_temperature_idx(),
39+
)
40+
41+
async def __start_ac_blowing(self) -> None:
42+
LOG.info("A/C will be set to blowing only")
43+
await self.saic_api.start_ac_blowing(self.vin)
44+
45+
async def __stop_ac(self) -> None:
46+
LOG.info("A/C will be switched off")
47+
await self.saic_api.stop_ac(self.vin)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from typing import override
5+
6+
from exceptions import MqttGatewayException
7+
from handlers.command.base import IntCommandHandler
8+
import mqtt_topics
9+
10+
LOG = logging.getLogger(__name__)
11+
12+
13+
class ClimateRemoteTemperatureCommand(IntCommandHandler):
14+
@classmethod
15+
@override
16+
def topic(cls) -> str:
17+
return mqtt_topics.CLIMATE_REMOTE_TEMPERATURE_SET
18+
19+
@override
20+
async def handle_typed_payload(self, temp: int) -> bool:
21+
try:
22+
LOG.info("Setting remote climate target temperature to %d", temp)
23+
changed = self.vehicle_state.set_ac_temperature(temp)
24+
if changed and self.vehicle_state.is_remote_ac_running:
25+
await self.saic_api.start_ac(
26+
self.vin,
27+
temperature_idx=self.vehicle_state.get_ac_temperature_idx(),
28+
)
29+
30+
except ValueError as e:
31+
msg = f"Error setting temperature target: {e}"
32+
raise MqttGatewayException(msg) from e
33+
return True
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
from handlers.command.doors.doors_boot import DoorsBootCommand
6+
from handlers.command.doors.doors_locked import DoorsLockedCommand
7+
8+
if TYPE_CHECKING:
9+
from handlers.command import CommandHandlerBase
10+
11+
ALL_COMMAND_HANDLERS: list[type[CommandHandlerBase]] = [
12+
DoorsBootCommand,
13+
DoorsLockedCommand,
14+
]

0 commit comments

Comments
 (0)