Skip to content
Draft
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 38 additions & 1 deletion src/saic_ismart_client_ng/api/vehicle/climate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down
19 changes: 19 additions & 0 deletions src/saic_ismart_client_ng/api/vehicle/climate/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions src/saic_ismart_client_ng/api/vehicle/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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


Expand All @@ -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"

Expand Down
114 changes: 114 additions & 0 deletions tests/test_ventilated_seats.py
Original file line number Diff line number Diff line change
@@ -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
Loading