diff --git a/README.md b/README.md index 06f996f..e357e04 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Supported functionality (partial list, check the code for more) * start rear window heating * stop rear window heating * remote climate -* heated seats +* heated seats: each seat on its own, front and rear, plus rear seat status * heated steering wheel diff --git a/src/saic_ismart_client_ng/api/vehicle/climate/__init__.py b/src/saic_ismart_client_ng/api/vehicle/climate/__init__.py index cfda490..a03fdeb 100644 --- a/src/saic_ismart_client_ng/api/vehicle/climate/__init__.py +++ b/src/saic_ismart_client_ng/api/vehicle/climate/__init__.py @@ -8,8 +8,14 @@ VehicleControlReq, VehicleControlResp, ) +from saic_ismart_client_ng.api.vehicle.climate.schema import ( + REAR_HEATED_SEAT_ON_LEVEL, + HeatedSeat, +) from saic_ismart_client_ng.crypto_utils import sha256_hex_digest +__all__ = ["REAR_HEATED_SEAT_ON_LEVEL", "HeatedSeat"] + class SaicVehicleClimateApi(SaicVehicleApi): async def start_ac( @@ -89,6 +95,32 @@ async def control_heated_seats( ) return await self.send_vehicle_control_command(body, vin) + async def control_heated_seat( + self, vin: str, *, seat: HeatedSeat, level: int + ) -> VehicleControlResp: + """Set one heated seat, leaving the others alone. + + Matches what the iSmart app sends (decrypted MG S6 EV traffic): request + type 5 with a single parameter for the chosen seat (17 front left, + 18 front right, 25 rear left, 26 rear right) and no end-of-parameters + marker. Unlike ``control_heated_seats`` it doesn't have to re-send the + other front seat's level, and it can reach the rear seats. + + Front seats: 0 off, 1 low, 2 medium, 3 high. Rear seats are on/off + only (in the app and in the car): send ``REAR_HEATED_SEAT_ON_LEVEL`` + (3, what the app sends) for on and 0 for off. + """ + if not 0 <= level <= 3: + msg = f"Heated seat level must be 0-3, got {level}" + raise ValueError(msg) + rvc_params = [RvcParams(seat.value, level.to_bytes(1, "big"))] + body = VehicleControlReq( + rvc_req_type=RvcReqType.HEATED_SEATS, + rvc_params=rvc_params, + vin=sha256_hex_digest(vin), + ) + return await self.send_vehicle_control_command(body, vin) + async def control_heated_steering_wheel( self, vin: str, *, enable: bool ) -> VehicleControlResp: diff --git a/src/saic_ismart_client_ng/api/vehicle/climate/schema.py b/src/saic_ismart_client_ng/api/vehicle/climate/schema.py new file mode 100644 index 0000000..86be7bf --- /dev/null +++ b/src/saic_ismart_client_ng/api/vehicle/climate/schema.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from enum import Enum + +from saic_ismart_client_ng.api.vehicle.schema import RvcParamsId + + +class HeatedSeat(Enum): + """A single heated seat, by the parameter the iSmart app uses for it.""" + + FRONT_LEFT = RvcParamsId.HEATED_SEAT_DRIVER + FRONT_RIGHT = RvcParamsId.HEATED_SEAT_PASSENGER + REAR_LEFT = RvcParamsId.HEATED_SEAT_REAR_LEFT + REAR_RIGHT = RvcParamsId.HEATED_SEAT_REAR_RIGHT + + +# Rear seats are on/off only; the iSmart app sends this level for "on". +REAR_HEATED_SEAT_ON_LEVEL = 3 diff --git a/src/saic_ismart_client_ng/api/vehicle/schema.py b/src/saic_ismart_client_ng/api/vehicle/schema.py index f0bbaab..cba2785 100644 --- a/src/saic_ismart_client_ng/api/vehicle/schema.py +++ b/src/saic_ismart_client_ng/api/vehicle/schema.py @@ -99,6 +99,11 @@ class BasicVehicleStatus: rearRightWindow: int | None = None remoteClimateStatus: int | None = None rmtHtdRrWndSt: int | None = None + # Rear (second row) heated seats. Reported by the car whatever turned them + # on (app, remote command or the in-car buttons). The iSmart app and the + # car only offer on/off for the rear: 0 = off, non-zero = on. + secondRowLeftSeatHeatLevel: int | None = None + secondRowRightSeatHeatLevel: int | None = None sideLightStatus: int | None = None steeringHeatLevel: int | None = None steeringWheelHeatFailureReason: int | None = None @@ -174,6 +179,8 @@ class RvcParamsId(Enum): AC_ON_OFF = 22 REMOTE_HEAT_REAR_WINDOW = 23 HEATED_STEERING_WHEEL = 24 + HEATED_SEAT_REAR_LEFT = 25 + HEATED_SEAT_REAR_RIGHT = 26 PARAMS_MAX = 0xFF diff --git a/tests/test_heated_seats.py b/tests/test_heated_seats.py new file mode 100644 index 0000000..7920c54 --- /dev/null +++ b/tests/test_heated_seats.py @@ -0,0 +1,75 @@ +"""Per-seat heated seat control, front and rear (#34). + +Checks the request body against what the iSmart app sends (decrypted MG S6 EV +traffic): request type 5, a single parameter for the chosen seat (17 front +left, 18 front right, 25 rear left, 26 rear right) and no end-of-parameters +marker. Rear seats are on/off only; the app sends 3 for "on". +""" + +from __future__ import annotations + +import base64 +from typing import Any +import unittest + +import pytest + +from saic_ismart_client_ng import SaicApi +from saic_ismart_client_ng.api.vehicle import RvcReqType +from saic_ismart_client_ng.api.vehicle.climate import ( + REAR_HEATED_SEAT_ON_LEVEL, + HeatedSeat, +) +from saic_ismart_client_ng.crypto_utils import sha256_hex_digest +from saic_ismart_client_ng.model import SaicApiConfiguration + +VIN = "OFFLINE0000000000" +SEAT_PARAMS = { + HeatedSeat.FRONT_LEFT: 17, + HeatedSeat.FRONT_RIGHT: 18, + HeatedSeat.REAR_LEFT: 25, + HeatedSeat.REAR_RIGHT: 26, +} + + +class TestHeatedSeat(unittest.IsolatedAsyncioTestCase): + async def _sent(self, *, seat: HeatedSeat, level: int) -> Any: + api = SaicApi(SaicApiConfiguration(username="user", password="pass")) # noqa: S106 + captured: dict[str, Any] = {} + + async def capture(body: Any, vin: str) -> None: + captured["body"] = body + captured["vin"] = vin + + api.send_vehicle_control_command = capture # type: ignore[method-assign,assignment] + try: + await api.control_heated_seat(VIN, seat=seat, level=level) + finally: + await api.close() + return captured.get("body") + + async def test_one_seat_per_command(self) -> None: + for seat, param_id in SEAT_PARAMS.items(): + for level in range(4): + with self.subTest(seat=seat, level=level): + body = await self._sent(seat=seat, level=level) + assert body.rvcReqType == RvcReqType.HEATED_SEATS.value == "5" + params = [ + (p.paramId, base64.b64decode(p.paramValue)) + for p in body.rvcParams + ] + assert params == [(param_id, bytes([level]))] + assert body.vin == sha256_hex_digest(VIN) + + async def test_rear_seat_on_is_the_apps_level(self) -> None: + assert REAR_HEATED_SEAT_ON_LEVEL == 3 + body = await self._sent( + seat=HeatedSeat.REAR_LEFT, level=REAR_HEATED_SEAT_ON_LEVEL + ) + params = [(p.paramId, base64.b64decode(p.paramValue)) for p in body.rvcParams] + assert params == [(25, b"\x03")] + + async def test_out_of_range_levels_are_rejected(self) -> None: + for level in (-1, 4): + with self.subTest(level=level), pytest.raises(ValueError, match="0-3"): + await self._sent(seat=HeatedSeat.FRONT_LEFT, level=level) diff --git a/tests/test_vehicle_status_rear_seats.py b/tests/test_vehicle_status_rear_seats.py new file mode 100644 index 0000000..738ac6c --- /dev/null +++ b/tests/test_vehicle_status_rear_seats.py @@ -0,0 +1,47 @@ +"""Rear (second row) heated seat levels in the vehicle status (#34). + +The /vehicle/status response from an MG S6 EV carries +secondRowLeftSeatHeatLevel and secondRowRightSeatHeatLevel, which were +dropped because BasicVehicleStatus had no fields for them. +""" + +from __future__ import annotations + +import json +import unittest + +import dacite + +from saic_ismart_client_ng.api.vehicle import VehicleStatusResp + +# Trimmed MG S6 EV (series MIS3E) response with one rear seat heating. +MGS6_STATUS = """{ + "basicVehicleStatus": { + "frontLeftSeatHeatLevel": 0, + "frontRightSeatHeatLevel": 2, + "secondRowLeftSeatHeatLevel": 3, + "secondRowRightSeatHeatLevel": 0, + "steeringHeatLevel": 0, + "lockStatus": 1 + }, + "extendedVehicleStatus": {"alertDataSum": [0, 0, 0]}, + "statusTime": 1790714718 +}""" + + +class TestRearSeatHeatLevels(unittest.TestCase): + def test_rear_seat_heat_levels(self) -> None: + basic = dacite.from_dict(VehicleStatusResp, json.loads(MGS6_STATUS)) + assert basic.basicVehicleStatus is not None + assert basic.basicVehicleStatus.secondRowLeftSeatHeatLevel == 3 + assert basic.basicVehicleStatus.secondRowRightSeatHeatLevel == 0 + assert basic.basicVehicleStatus.frontRightSeatHeatLevel == 2 + + def test_absent_on_cars_without_them(self) -> None: + payload = json.loads(MGS6_STATUS) + del payload["basicVehicleStatus"]["secondRowLeftSeatHeatLevel"] + del payload["basicVehicleStatus"]["secondRowRightSeatHeatLevel"] + status = dacite.from_dict(VehicleStatusResp, payload) + assert status.basicVehicleStatus is not None + assert status.basicVehicleStatus.secondRowLeftSeatHeatLevel is None + assert status.basicVehicleStatus.secondRowRightSeatHeatLevel is None