From ca10640313db40d91a94db219a686df14db1aef3 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sun, 25 Oct 2026 16:55:03 +0100 Subject: [PATCH] feat: ventilated seat control and status (#35) control_ventilated_seat(vin, seat=VentilatedSeat.X, level=0-3) sets one seat per command, front or rear: request type 22, a single parameter (27 front left, 28 front right, 29 rear left, 30 rear right), then PARAMS_MAX. BasicVehicleStatus gets frontLeft/frontRight/secondRowLeft/secondRowRight SeatVentLevel. Based on static analysis of the iSmart app, not checked on a car. --- README.md | 1 + .../api/vehicle/climate/__init__.py | 39 +++++- .../api/vehicle/climate/schema.py | 19 +++ .../api/vehicle/schema.py | 10 ++ tests/test_ventilated_seats.py | 114 ++++++++++++++++++ 5 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 tests/test_ventilated_seats.py diff --git a/README.md b/README.md index e357e04..1af8e81 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Supported functionality (partial list, check the code for more) * remote climate * heated seats: each seat on its own, front and rear, plus rear seat status * heated steering wheel +* ventilated seats: each seat on its own, front and rear (untested on a car) ## Prerequisites 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 643eb22..0bc24ab 100644 --- a/src/saic_ismart_client_ng/api/vehicle/climate/__init__.py +++ b/src/saic_ismart_client_ng/api/vehicle/climate/__init__.py @@ -9,12 +9,19 @@ VehicleControlResp, ) from saic_ismart_client_ng.api.vehicle.climate.schema import ( + ONE_LEVEL_VENTILATED_SEAT_ON_LEVEL, REAR_HEATED_SEAT_ON_LEVEL, HeatedSeat, + VentilatedSeat, ) from saic_ismart_client_ng.crypto_utils import sha256_hex_digest -__all__ = ["REAR_HEATED_SEAT_ON_LEVEL", "HeatedSeat"] +__all__ = [ + "ONE_LEVEL_VENTILATED_SEAT_ON_LEVEL", + "REAR_HEATED_SEAT_ON_LEVEL", + "HeatedSeat", + "VentilatedSeat", +] class SaicVehicleClimateApi(SaicVehicleApi): @@ -124,6 +131,36 @@ async def control_heated_seat( ) return await self.send_vehicle_control_command(body, vin) + async def control_ventilated_seat( + self, vin: str, *, seat: VentilatedSeat, level: int + ) -> VehicleControlResp: + """Set one ventilated seat, leaving the others alone. + + Sends request type 22 with a single parameter for the chosen seat (27 + front left, 28 front right, 29 rear left, 30 rear right), followed by + the end-of-parameters marker (255) like the other control commands. + Not every car has ventilated seats, and the rear ones are rarer than + the front ones: check the car's capabilities first. + + 0 is off. Seats with several levels take 1, 2 or 3. Seats with a + single level (on/off only) take ``ONE_LEVEL_VENTILATED_SEAT_ON_LEVEL`` + (3) for on. This is based on the iSmart app's logic and has not been + checked on a car. + """ + if not 0 <= level <= 3: + msg = f"Ventilated seat level must be 0-3, got {level}" + raise ValueError(msg) + rvc_params = [ + RvcParams(seat.value, level.to_bytes(1, "big")), + RvcParams(RvcParamsId.PARAMS_MAX, b"\x00\x00\x00\x00"), + ] + body = VehicleControlReq( + rvc_req_type=RvcReqType.VENTILATED_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 index daf46b7..b6eb6ba 100644 --- a/src/saic_ismart_client_ng/api/vehicle/climate/schema.py +++ b/src/saic_ismart_client_ng/api/vehicle/climate/schema.py @@ -21,3 +21,22 @@ class HeatedSeat(Enum): # Rear seats are on/off only; the iSmart app sends this level for "on". REAR_HEATED_SEAT_ON_LEVEL = 3 + + +class VentilatedSeat(Enum): + """A single ventilated seat, by the parameter the iSmart app uses for it. + + Left and right are the physical sides of the car. They match + ``frontLeftSeatVentLevel`` / ``frontRightSeatVentLevel`` and + ``secondRowLeftSeatVentLevel`` / ``secondRowRightSeatVentLevel`` in the + vehicle status. + """ + + FRONT_LEFT = RvcParamsId.SEAT_VENTILATION_FRONT_LEFT + FRONT_RIGHT = RvcParamsId.SEAT_VENTILATION_FRONT_RIGHT + REAR_LEFT = RvcParamsId.SEAT_VENTILATION_REAR_LEFT + REAR_RIGHT = RvcParamsId.SEAT_VENTILATION_REAR_RIGHT + + +# Seats with a single ventilation level (on/off only) take this level for "on". +ONE_LEVEL_VENTILATED_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 742207a..cd3d34a 100644 --- a/src/saic_ismart_client_ng/api/vehicle/schema.py +++ b/src/saic_ismart_client_ng/api/vehicle/schema.py @@ -76,8 +76,10 @@ class BasicVehicleStatus: extendedData2: int | None = None exteriorTemperature: int | None = None frontLeftSeatHeatLevel: int | None = None + frontLeftSeatVentLevel: int | None = None frontLeftTyrePressure: int | None = None frontRightSeatHeatLevel: int | None = None + frontRightSeatVentLevel: int | None = None frontRightTyrePressure: int | None = None fuelLevelPrc: int | None = None fuelRange: int | None = None @@ -103,7 +105,9 @@ class BasicVehicleStatus: # 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 + secondRowLeftSeatVentLevel: int | None = None secondRowRightSeatHeatLevel: int | None = None + secondRowRightSeatVentLevel: int | None = None sideLightStatus: int | None = None steeringHeatLevel: int | None = None steeringWheelHeatFailureReason: int | None = None @@ -188,6 +192,11 @@ class RvcParamsId(Enum): HEATED_STEERING_WHEEL = 24 HEATED_SEAT_REAR_LEFT = 25 HEATED_SEAT_REAR_RIGHT = 26 + # Ventilated seats, addressed by physical side like the heated seats. + SEAT_VENTILATION_FRONT_LEFT = 27 + SEAT_VENTILATION_FRONT_RIGHT = 28 + SEAT_VENTILATION_REAR_LEFT = 29 + SEAT_VENTILATION_REAR_RIGHT = 30 PARAMS_MAX = 0xFF @@ -214,6 +223,7 @@ class RvcReqType(Enum): ENGINE_CONTROL = "17" REMOTE_REFRESH = "18" REMOTE_IMMOBILIZER = "19" + VENTILATED_SEATS = "22" REMOTE_HEAT_REAR_WINDOW = "32" MAX_VALUE = "597" diff --git a/tests/test_ventilated_seats.py b/tests/test_ventilated_seats.py new file mode 100644 index 0000000..1a076d2 --- /dev/null +++ b/tests/test_ventilated_seats.py @@ -0,0 +1,114 @@ +"""Per-seat ventilated seat control and status (#35). + +Checks the request body: request type 22, a single parameter for the chosen +seat (27 front left, 28 front right, 29 rear left, 30 rear right), then the +end-of-parameters marker (255). Seats with a single level take 3 for "on". + +This follows the iSmart app's logic. It has not been checked on a car. +""" + +from __future__ import annotations + +import base64 +import json +from typing import Any +import unittest + +import dacite +import pytest + +from saic_ismart_client_ng import SaicApi +from saic_ismart_client_ng.api.vehicle import RvcReqType, VehicleStatusResp +from saic_ismart_client_ng.api.vehicle.climate import ( + ONE_LEVEL_VENTILATED_SEAT_ON_LEVEL, + VentilatedSeat, +) +from saic_ismart_client_ng.crypto_utils import sha256_hex_digest +from saic_ismart_client_ng.model import SaicApiConfiguration + +VIN = "OFFLINE0000000000" +SEAT_PARAMS = { + VentilatedSeat.FRONT_LEFT: 27, + VentilatedSeat.FRONT_RIGHT: 28, + VentilatedSeat.REAR_LEFT: 29, + VentilatedSeat.REAR_RIGHT: 30, +} + + +class TestVentilatedSeat(unittest.IsolatedAsyncioTestCase): + async def _sent(self, *, seat: VentilatedSeat, 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 + + api.send_vehicle_control_command = capture # type: ignore[method-assign,assignment] + try: + await api.control_ventilated_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.VENTILATED_SEATS.value == "22" + params = [ + (p.paramId, base64.b64decode(p.paramValue)) + for p in body.rvcParams + ] + assert params == [ + (param_id, bytes([level])), + (255, b"\x00\x00\x00\x00"), + ] + assert body.vin == sha256_hex_digest(VIN) + + async def test_one_level_seat_on_level(self) -> None: + assert ONE_LEVEL_VENTILATED_SEAT_ON_LEVEL == 3 + body = await self._sent( + seat=VentilatedSeat.FRONT_LEFT, level=ONE_LEVEL_VENTILATED_SEAT_ON_LEVEL + ) + params = [(p.paramId, base64.b64decode(p.paramValue)) for p in body.rvcParams] + assert params == [(27, b"\x03"), (255, b"\x00\x00\x00\x00")] + + 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=VentilatedSeat.FRONT_LEFT, level=level) + + +class TestVentilatedSeatStatus(unittest.TestCase): + PAYLOAD = """{ + "basicVehicleStatus": { + "frontLeftSeatVentLevel": 0, + "frontRightSeatVentLevel": 2, + "secondRowLeftSeatVentLevel": 3, + "secondRowRightSeatVentLevel": 0, + "lockStatus": 1 + }, + "extendedVehicleStatus": {"alertDataSum": [0, 0, 0]}, + "statusTime": 1790714718 + }""" + + def test_levels_are_parsed(self) -> None: + status = dacite.from_dict(VehicleStatusResp, json.loads(self.PAYLOAD)) + basic = status.basicVehicleStatus + assert basic is not None + assert basic.frontLeftSeatVentLevel == 0 + assert basic.frontRightSeatVentLevel == 2 + assert basic.secondRowLeftSeatVentLevel == 3 + assert basic.secondRowRightSeatVentLevel == 0 + + def test_absent_on_cars_without_them(self) -> None: + payload = json.loads(self.PAYLOAD) + for key in list(payload["basicVehicleStatus"]): + if "Vent" in key: + del payload["basicVehicleStatus"][key] + status = dacite.from_dict(VehicleStatusResp, payload) + basic = status.basicVehicleStatus + assert basic is not None + assert basic.frontLeftSeatVentLevel is None + assert basic.secondRowRightSeatVentLevel is None