Skip to content
Open
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
19 changes: 18 additions & 1 deletion src/handlers/command/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from saic_ismart_client_ng import SaicApi

from publisher.core import Publisher
from publisher.core import Publishable, Publisher
from vehicle import VehicleState


Expand Down Expand Up @@ -68,6 +68,23 @@ async def handle(
) -> CommandProcessingResult:
raise NotImplementedError

# Optional eager-publish + rollback hooks. A handler opts in by overriding
# `state_topic` to return a non-None topic, in which case the dispatcher
# will publish `expected_state(payload)` to that topic on receipt and
# republish `current_state` if the SAIC call later fails. See
# `VehicleCommandHandler` in `handlers/vehicle_command.py`.

@property
def state_topic(self) -> str | None:
return None

@property
def current_state(self) -> Publishable | None:
return None

def expected_state(self, _raw_payload: str) -> Publishable | None:
return None

@property
def saic_api(self) -> SaicApi:
return self.__saic_api
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,35 @@ class DrivetrainBatteryHeatingScheduleCommand(
def topic(cls) -> str:
return mqtt_topics.DRIVETRAIN_BATTERY_HEATING_SCHEDULE_SET

@property
@override
def state_topic(self) -> str:
return mqtt_topics.DRIVETRAIN_BATTERY_HEATING_SCHEDULE

@property
@override
def current_state(self) -> dict[str, Any] | None:
start_time = self.vehicle_state.scheduled_battery_heating_start
if start_time is None:
return None
return {
"startTime": start_time.strftime("%H:%M"),
"mode": "on"
if self.vehicle_state.scheduled_battery_heating_enabled
else "off",
}

@override
def expected_state(self, raw_payload: str) -> dict[str, Any] | None:
try:
parsed = self.convert_payload(raw_payload)
except (ValueError, KeyError, json.JSONDecodeError):
return None
return {
"startTime": parsed.start_time.strftime("%H:%M"),
"mode": "on" if parsed.enable else "off",
}

@staticmethod
@override
def convert_payload(payload: str) -> BatteryHeatingScheduleCommandPayload:
Expand All @@ -54,20 +83,27 @@ async def handle_typed_payload(
) -> CommandProcessingResult:
start_time = payload.start_time
should_enable = payload.enable
changed = self.vehicle_state.update_scheduled_battery_heating(
start_time, should_enable
)
if changed:
if should_enable:
LOG.info(f"Setting battery heating schedule to {start_time}")
await self.saic_api.enable_schedule_battery_heating(
self.vin,
start_time=start_time,
tz=self.vehicle_state.user_timezone,
)
else:
LOG.info("Disabling battery heating schedule")
await self.saic_api.disable_schedule_battery_heating(self.vin)
else:
# Mutate in-memory state only after the SAIC call succeeds: otherwise
# an API failure leaves the gateway holding the failed-new value, so
# the next eager-echo `current_state` would be wrong on rollback.
if (
self.vehicle_state.scheduled_battery_heating_start == start_time
and self.vehicle_state.scheduled_battery_heating_enabled == should_enable
):
# No state change: skip the canonical-schedule republish that
# `update_scheduled_battery_heating` would do as a side effect.
# The eager echo already stamped the broker so it stays consistent.
LOG.info("Battery heating schedule not changed")
return RESULT_REFRESH_ONLY
if should_enable:
LOG.info(f"Setting battery heating schedule to {start_time}")
await self.saic_api.enable_schedule_battery_heating(
self.vin,
start_time=start_time,
tz=self.vehicle_state.user_timezone,
)
else:
LOG.info("Disabling battery heating schedule")
await self.saic_api.disable_schedule_battery_heating(self.vin)
self.vehicle_state.update_scheduled_battery_heating(start_time, should_enable)
return RESULT_REFRESH_ONLY
19 changes: 19 additions & 0 deletions src/handlers/command/drivetrain/drivetrain_chargecurrent_limit.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,28 @@ class DrivetrainChargeCurrentLimitCommand(
PayloadConvertingCommandHandler[ChargeCurrentLimitCode]
):
@classmethod
@override
def topic(cls) -> str:
return mqtt_topics.DRIVETRAIN_CHARGECURRENT_LIMIT_SET

@property
@override
def state_topic(self) -> str:
return mqtt_topics.DRIVETRAIN_CHARGECURRENT_LIMIT

@property
@override
def current_state(self) -> str | None:
limit = self.vehicle_state.charge_current_limit
return None if limit is None else limit.limit

@override
def expected_state(self, raw_payload: str) -> str | None:
try:
return self.convert_payload(raw_payload).limit
except (ValueError, KeyError):
return None

@staticmethod
@override
def convert_payload(payload: str) -> ChargeCurrentLimitCode:
Expand Down
38 changes: 37 additions & 1 deletion src/handlers/command/drivetrain/drivetrain_charging_schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
PayloadConvertingCommandHandler,
)
import mqtt_topics
from status_publisher.charge.chrg_mgmt_data import ScheduledCharging

if TYPE_CHECKING:
from collections.abc import Mapping
Expand Down Expand Up @@ -45,6 +46,35 @@ class DrivetrainChargingScheduleCommand(
def topic(cls) -> str:
return mqtt_topics.DRIVETRAIN_CHARGING_SCHEDULE_SET

@property
@override
def state_topic(self) -> str:
return mqtt_topics.DRIVETRAIN_CHARGING_SCHEDULE

@property
@override
def current_state(self) -> dict[str, Any] | None:
schedule = self.vehicle_state.scheduled_charging
if schedule is None:
return None
return {
"startTime": schedule.start_time.strftime("%H:%M"),
"endTime": schedule.end_time.strftime("%H:%M"),
"mode": schedule.mode.name,
}

@override
def expected_state(self, raw_payload: str) -> dict[str, Any] | None:
try:
parsed = self.convert_payload(raw_payload)
except (ValueError, KeyError, json.JSONDecodeError):
return None
return {
"startTime": parsed.start_time.strftime("%H:%M"),
"endTime": parsed.end_time.strftime("%H:%M"),
"mode": parsed.mode.name,
}

@staticmethod
@override
def convert_payload(payload: str) -> ChargingScheduleCommandPayload:
Expand All @@ -71,5 +101,11 @@ async def handle_typed_payload(
end_time=payload.end_time,
mode=payload.mode,
)
self.vehicle_state.update_scheduled_charging(payload.start_time, payload.mode)
self.vehicle_state.update_scheduled_charging(
ScheduledCharging(
start_time=payload.start_time,
end_time=payload.end_time,
mode=payload.mode,
)
)
return RESULT_REFRESH_ONLY
18 changes: 18 additions & 0 deletions src/handlers/command/drivetrain/drivetrain_soc_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,24 @@ class DrivetrainSoCTargetCommand(PayloadConvertingCommandHandler[TargetBatteryCo
def topic(cls) -> str:
return mqtt_topics.DRIVETRAIN_SOC_TARGET_SET

@property
@override
def state_topic(self) -> str:
return mqtt_topics.DRIVETRAIN_SOC_TARGET

@property
@override
def current_state(self) -> int | None:
target = self.vehicle_state.target_soc
return None if target is None else target.percentage

@override
def expected_state(self, raw_payload: str) -> int | None:
try:
return self.convert_payload(raw_payload).percentage
except (ValueError, KeyError):
return None

@staticmethod
@override
def convert_payload(payload: str) -> TargetBatteryCode:
Expand Down
Loading