Skip to content

Commit 40de3bb

Browse files
authored
Merge pull request #357 from nanomad/feature/configurable-battery-capacity
Feature: Configurable Battery Capacity via MQTT
2 parents c774ba3 + 62d281c commit 40de3bb

31 files changed

+375
-101
lines changed

src/handlers/command/base.py

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from abc import ABCMeta, abstractmethod
4+
import dataclasses
45
from typing import TYPE_CHECKING, Final, override
56

67
from exceptions import MqttGatewayException
@@ -14,6 +15,26 @@
1415
from vehicle import VehicleState
1516

1617

18+
@dataclasses.dataclass(kw_only=True, frozen=True)
19+
class CommandProcessingResult:
20+
force_refresh: bool
21+
clear_command: bool
22+
23+
24+
RESULT_DO_NOTHING: Final[CommandProcessingResult] = CommandProcessingResult(
25+
force_refresh=False, clear_command=False
26+
)
27+
RESULT_REFRESH_AND_CLEAR: Final[CommandProcessingResult] = CommandProcessingResult(
28+
force_refresh=True, clear_command=True
29+
)
30+
RESULT_CLEAR_ONLY: Final[CommandProcessingResult] = CommandProcessingResult(
31+
force_refresh=False, clear_command=True
32+
)
33+
RESULT_REFRESH_ONLY: Final[CommandProcessingResult] = CommandProcessingResult(
34+
force_refresh=True, clear_command=False
35+
)
36+
37+
1738
class CommandHandlerBase(metaclass=ABCMeta):
1839
def __init__(self, saic_api: SaicApi, vehicle_state: VehicleState) -> None:
1940
self.__saic_api: Final[SaicApi] = saic_api
@@ -29,7 +50,7 @@ def topic(cls) -> str:
2950
raise NotImplementedError
3051

3152
@abstractmethod
32-
async def handle(self, payload: str) -> bool:
53+
async def handle(self, payload: str) -> CommandProcessingResult:
3354
raise NotImplementedError
3455

3556
@property
@@ -50,23 +71,32 @@ def publisher(self) -> Publisher:
5071

5172

5273
class MultiValuedCommandHandler[T](CommandHandlerBase, metaclass=ABCMeta):
53-
async def should_refresh(self, _action_result: T) -> bool:
54-
return True
74+
@abstractmethod
75+
async def _get_action_result(self, _action_result: T) -> CommandProcessingResult:
76+
pass
5577

5678
@abstractmethod
5779
def options(self) -> dict[str, Callable[[], Awaitable[T]]]:
5880
raise NotImplementedError
5981

82+
@property
83+
def supports_empty_payload(self) -> bool:
84+
return False
85+
6086
@override
61-
async def handle(self, payload: str) -> bool:
87+
async def handle(self, payload: str) -> CommandProcessingResult:
6288
normalized_payload = payload.strip().lower()
89+
90+
if len(normalized_payload) == 0 and not self.supports_empty_payload:
91+
return RESULT_DO_NOTHING
92+
6393
options = self.options()
6494
option_handler = options.get(normalized_payload)
6595
if option_handler is None:
6696
msg = f"Unsupported payload {payload} for command {self.name()}"
6797
raise MqttGatewayException(msg)
6898
response = await option_handler()
69-
return await self.should_refresh(response)
99+
return await self._get_action_result(response)
70100

71101

72102
class BooleanCommandHandler[T](CommandHandlerBase, metaclass=ABCMeta):
@@ -78,20 +108,26 @@ async def handle_true(self) -> T:
78108
async def handle_false(self) -> T:
79109
raise NotImplementedError
80110

81-
async def should_refresh(self, _action_result: T) -> bool:
82-
return True
111+
@abstractmethod
112+
async def _get_action_result(self, _action_result: T) -> CommandProcessingResult:
113+
pass
83114

84115
@override
85-
async def handle(self, payload: str) -> bool:
86-
match payload.strip().lower():
116+
async def handle(self, payload: str) -> CommandProcessingResult:
117+
normalized_payload = payload.strip().lower()
118+
119+
if len(normalized_payload) == 0:
120+
return RESULT_DO_NOTHING
121+
122+
match normalized_payload:
87123
case "true" | "1" | "on":
88124
response = await self.handle_true()
89125
case "false" | "0" | "off":
90126
response = await self.handle_false()
91127
case _:
92128
msg = f"Unsupported payload {payload} for command {self.name()}"
93129
raise MqttGatewayException(msg)
94-
return await self.should_refresh(response)
130+
return await self._get_action_result(response)
95131

96132

97133
class PayloadConvertingCommandHandler[T](CommandHandlerBase, metaclass=ABCMeta):
@@ -101,11 +137,18 @@ def convert_payload(payload: str) -> T:
101137
raise NotImplementedError
102138

103139
@abstractmethod
104-
async def handle_typed_payload(self, payload: T) -> bool:
140+
async def handle_typed_payload(self, payload: T) -> CommandProcessingResult:
105141
raise NotImplementedError
106142

143+
@property
144+
def supports_empty_payload(self) -> bool:
145+
return False
146+
107147
@override
108-
async def handle(self, payload: str) -> bool:
148+
async def handle(self, payload: str) -> CommandProcessingResult:
149+
if len(payload.strip()) == 0 and not self.supports_empty_payload:
150+
return RESULT_DO_NOTHING
151+
109152
try:
110153
converted_payload = self.convert_payload(payload)
111154
except Exception as e:
@@ -119,3 +162,9 @@ class IntCommandHandler(PayloadConvertingCommandHandler[int], metaclass=ABCMeta)
119162
@staticmethod
120163
def convert_payload(payload: str) -> int:
121164
return int(payload.strip().lower())
165+
166+
167+
class FloatCommandHandler(PayloadConvertingCommandHandler[float], metaclass=ABCMeta):
168+
@staticmethod
169+
def convert_payload(payload: str) -> float:
170+
return float(payload.strip().lower())

src/handlers/command/climate/climate_back_window_heat.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
import logging
44
from typing import override
55

6-
from handlers.command.base import BooleanCommandHandler
6+
from handlers.command.base import (
7+
RESULT_REFRESH_AND_CLEAR,
8+
BooleanCommandHandler,
9+
CommandProcessingResult,
10+
)
711
import mqtt_topics
812

913
LOG = logging.getLogger(__name__)
@@ -24,3 +28,7 @@ async def handle_true(self) -> None:
2428
async def handle_false(self) -> None:
2529
LOG.info("Rear window heating will be switched off")
2630
await self.saic_api.control_rear_window_heat(self.vin, enable=False)
31+
32+
@override
33+
async def _get_action_result(self, _action_result: None) -> CommandProcessingResult:
34+
return RESULT_REFRESH_AND_CLEAR

src/handlers/command/climate/climate_front_window_heat.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
import logging
44
from typing import override
55

6-
from handlers.command.base import BooleanCommandHandler
6+
from handlers.command.base import (
7+
RESULT_REFRESH_AND_CLEAR,
8+
BooleanCommandHandler,
9+
CommandProcessingResult,
10+
)
711
import mqtt_topics
812

913
LOG = logging.getLogger(__name__)
@@ -24,3 +28,7 @@ async def handle_true(self) -> None:
2428
async def handle_false(self) -> None:
2529
LOG.info("Front window heating will be switched off")
2630
await self.saic_api.stop_ac(self.vin)
31+
32+
@override
33+
async def _get_action_result(self, _action_result: None) -> CommandProcessingResult:
34+
return RESULT_REFRESH_AND_CLEAR

src/handlers/command/climate/climate_heated_seats_level.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44
from typing import override
55

66
from exceptions import MqttGatewayException
7-
from handlers.command.base import IntCommandHandler
7+
from handlers.command.base import (
8+
RESULT_REFRESH_AND_CLEAR,
9+
CommandProcessingResult,
10+
IntCommandHandler,
11+
)
812
import mqtt_topics
913

1014
LOG = logging.getLogger(__name__)
@@ -17,7 +21,7 @@ def topic(cls) -> str:
1721
return mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_LEFT_LEVEL_SET
1822

1923
@override
20-
async def handle_typed_payload(self, level: int) -> bool:
24+
async def handle_typed_payload(self, level: int) -> CommandProcessingResult:
2125
try:
2226
LOG.info("Setting heated seats front left level to %d", level)
2327
changed = self.vehicle_state.update_heated_seats_front_left_level(level)
@@ -32,7 +36,7 @@ async def handle_typed_payload(self, level: int) -> bool:
3236
except Exception as e:
3337
msg = f"Error setting heated seats: {e}"
3438
raise MqttGatewayException(msg) from e
35-
return True
39+
return RESULT_REFRESH_AND_CLEAR
3640

3741

3842
class ClimateHeatedSeatsFrontRightLevelCommand(IntCommandHandler):
@@ -42,7 +46,7 @@ def topic(cls) -> str:
4246
return mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_RIGHT_LEVEL_SET
4347

4448
@override
45-
async def handle_typed_payload(self, level: int) -> bool:
49+
async def handle_typed_payload(self, level: int) -> CommandProcessingResult:
4650
try:
4751
LOG.info("Setting heated seats front right level to %d", level)
4852
changed = self.vehicle_state.update_heated_seats_front_right_level(level)
@@ -57,4 +61,4 @@ async def handle_typed_payload(self, level: int) -> bool:
5761
except Exception as e:
5862
msg = f"Error setting heated seats: {e}"
5963
raise MqttGatewayException(msg) from e
60-
return True
64+
return RESULT_REFRESH_AND_CLEAR

src/handlers/command/climate/climate_remote_climate_state.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
import logging
44
from typing import TYPE_CHECKING, override
55

6-
from handlers.command.base import MultiValuedCommandHandler
6+
from handlers.command.base import (
7+
RESULT_REFRESH_AND_CLEAR,
8+
CommandProcessingResult,
9+
MultiValuedCommandHandler,
10+
)
711
import mqtt_topics
812

913
if TYPE_CHECKING:
@@ -27,6 +31,10 @@ def options(self) -> dict[str, Callable[[], Awaitable[None]]]:
2731
"front": self.__start_front_defrost,
2832
}
2933

34+
@override
35+
async def _get_action_result(self, _action_result: None) -> CommandProcessingResult:
36+
return RESULT_REFRESH_AND_CLEAR
37+
3038
async def __start_front_defrost(self) -> None:
3139
LOG.info("A/C will be set to front seats only")
3240
await self.saic_api.start_front_defrost(self.vin)

src/handlers/command/climate/climate_remote_temperature.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44
from typing import override
55

66
from exceptions import MqttGatewayException
7-
from handlers.command.base import IntCommandHandler
7+
from handlers.command.base import (
8+
RESULT_REFRESH_ONLY,
9+
CommandProcessingResult,
10+
IntCommandHandler,
11+
)
812
import mqtt_topics
913

1014
LOG = logging.getLogger(__name__)
@@ -17,7 +21,7 @@ def topic(cls) -> str:
1721
return mqtt_topics.CLIMATE_REMOTE_TEMPERATURE_SET
1822

1923
@override
20-
async def handle_typed_payload(self, temp: int) -> bool:
24+
async def handle_typed_payload(self, temp: int) -> CommandProcessingResult:
2125
try:
2226
LOG.info("Setting remote climate target temperature to %d", temp)
2327
changed = self.vehicle_state.set_ac_temperature(temp)
@@ -30,4 +34,4 @@ async def handle_typed_payload(self, temp: int) -> bool:
3034
except ValueError as e:
3135
msg = f"Error setting temperature target: {e}"
3236
raise MqttGatewayException(msg) from e
33-
return True
37+
return RESULT_REFRESH_ONLY

src/handlers/command/doors/doors_boot.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
import logging
44
from typing import override
55

6-
from handlers.command.base import BooleanCommandHandler
6+
from handlers.command.base import (
7+
RESULT_REFRESH_AND_CLEAR,
8+
BooleanCommandHandler,
9+
CommandProcessingResult,
10+
)
711
import mqtt_topics
812

913
LOG = logging.getLogger(__name__)
@@ -23,3 +27,7 @@ async def handle_true(self) -> None:
2327
async def handle_false(self) -> None:
2428
LOG.info(f"Vehicle {self.vin} boot will be unlocked")
2529
await self.saic_api.open_tailgate(self.vin)
30+
31+
@override
32+
async def _get_action_result(self, _action_result: None) -> CommandProcessingResult:
33+
return RESULT_REFRESH_AND_CLEAR

src/handlers/command/doors/doors_locked.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
import logging
44
from typing import override
55

6-
from handlers.command.base import BooleanCommandHandler
6+
from handlers.command.base import (
7+
RESULT_REFRESH_AND_CLEAR,
8+
BooleanCommandHandler,
9+
CommandProcessingResult,
10+
)
711
import mqtt_topics
812

913
LOG = logging.getLogger(__name__)
@@ -24,3 +28,7 @@ async def handle_true(self) -> None:
2428
async def handle_false(self) -> None:
2529
LOG.info(f"Vehicle {self.vin} will be unlocked")
2630
await self.saic_api.unlock_vehicle(self.vin)
31+
32+
@override
33+
async def _get_action_result(self, _action_result: None) -> CommandProcessingResult:
34+
return RESULT_REFRESH_AND_CLEAR

src/handlers/command/drivetrain/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
DrivetrainHVBatteryActiveCommand,
2323
)
2424
from handlers.command.drivetrain.drivetrain_soc_target import DrivetrainSoCTargetCommand
25+
from handlers.command.drivetrain.drivetrain_total_battery_capacity import (
26+
DrivetrainTotalBatteryCapacitySetCommand,
27+
)
2528

2629
if TYPE_CHECKING:
2730
from handlers.command import CommandHandlerBase
@@ -33,6 +36,7 @@
3336
DrivetrainChargingCommand,
3437
DrivetrainHVBatteryActiveCommand,
3538
DrivetrainSoCTargetCommand,
39+
DrivetrainTotalBatteryCapacitySetCommand,
3640
DrivetrainChargingScheduleCommand,
3741
DrivetrainChargingCableLockCommand,
3842
]

src/handlers/command/drivetrain/drivetrain_battery_heating.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55

66
from saic_ismart_client_ng.api.vehicle_charging import ChrgPtcHeatResp
77

8-
from handlers.command.base import BooleanCommandHandler
8+
from handlers.command.base import (
9+
RESULT_REFRESH_AND_CLEAR,
10+
BooleanCommandHandler,
11+
CommandProcessingResult,
12+
)
913
import mqtt_topics
1014

1115
LOG = logging.getLogger(__name__)
@@ -28,7 +32,9 @@ async def handle_false(self) -> ChrgPtcHeatResp:
2832
return await self.saic_api.control_battery_heating(self.vin, enable=False)
2933

3034
@override
31-
async def should_refresh(self, response: ChrgPtcHeatResp | None) -> bool:
35+
async def _get_action_result(
36+
self, response: ChrgPtcHeatResp | None
37+
) -> CommandProcessingResult:
3238
if response is not None and response.ptcHeatResp is not None:
3339
decoded = response.heating_stop_reason
3440
self.publisher.publish_str(
@@ -39,4 +45,4 @@ async def should_refresh(self, response: ChrgPtcHeatResp | None) -> bool:
3945
if decoded is None
4046
else decoded.name,
4147
)
42-
return True
48+
return RESULT_REFRESH_AND_CLEAR

0 commit comments

Comments
 (0)