Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
32 changes: 32 additions & 0 deletions src/saic_ismart_client_ng/api/vehicle/climate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
18 changes: 18 additions & 0 deletions src/saic_ismart_client_ng/api/vehicle/climate/schema.py
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions src/saic_ismart_client_ng/api/vehicle/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


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