From 197203f164233a0884dac216be624d1c069349c0 Mon Sep 17 00:00:00 2001 From: townsmcp <14061617+townsmcp@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:42:32 +0000 Subject: [PATCH 1/2] feat: heated steering wheel control (#33) control_heated_steering_wheel(vin, enable=...) sends what the iSmart app sends on an MG S6 EV (decrypted app traffic): request type 8 with a single parameter, 24 = 1 (on) or 0 (off), and no end-of-parameters marker. The car turns the heater off by itself after about 10 minutes; its state is already parsed as BasicVehicleStatus.steeringHeatLevel. --- README.md | 1 + .../api/vehicle/climate/__init__.py | 23 +++++++++ .../api/vehicle/schema.py | 2 + tests/test_heated_steering_wheel.py | 49 +++++++++++++++++++ 4 files changed, 75 insertions(+) create mode 100644 tests/test_heated_steering_wheel.py diff --git a/README.md b/README.md index 1bfdab2..6efd94a 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Supported functionality (partial list, check the code for more) * stop rear window heating * remote climate * heated seats +* heated steering wheel ## 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 fb47aa5..8e30392 100644 --- a/src/saic_ismart_client_ng/api/vehicle/climate/__init__.py +++ b/src/saic_ismart_client_ng/api/vehicle/climate/__init__.py @@ -89,6 +89,29 @@ async def control_heated_seats( ) return await self.send_vehicle_control_command(body, vin) + async def control_heated_steering_wheel( + self, vin: str, *, enable: bool + ) -> VehicleControlResp: + """Turn the heated steering wheel on or off. + + Matches what the iSmart app sends (decrypted MG S6 EV traffic): + request type 8 with a single parameter, 24 = 1 (on) or 0 (off), and + no end-of-parameters marker. The car turns the heater off by itself + after about 10 minutes. Its state is reported back in + ``BasicVehicleStatus.steeringHeatLevel``. + """ + rvc_params = [ + RvcParams( + RvcParamsId.HEATED_STEERING_WHEEL, b"\x01" if enable else b"\x00" + ), + ] + body = VehicleControlReq( + rvc_req_type=RvcReqType.HEATED_STEERING_WHEEL, + rvc_params=rvc_params, + vin=sha256_hex_digest(vin), + ) + return await self.send_vehicle_control_command(body, vin) + async def control_rear_window_heat( self, vin: str, *, enable: bool ) -> VehicleControlResp: diff --git a/src/saic_ismart_client_ng/api/vehicle/schema.py b/src/saic_ismart_client_ng/api/vehicle/schema.py index de37c30..f0bbaab 100644 --- a/src/saic_ismart_client_ng/api/vehicle/schema.py +++ b/src/saic_ismart_client_ng/api/vehicle/schema.py @@ -173,6 +173,7 @@ class RvcParamsId(Enum): TEMPERATURE = 20 AC_ON_OFF = 22 REMOTE_HEAT_REAR_WINDOW = 23 + HEATED_STEERING_WHEEL = 24 PARAMS_MAX = 0xFF @@ -195,6 +196,7 @@ class RvcReqType(Enum): HEATED_SEATS = "5" CLIMATE = "6" AIR_CLEAN = "7" + HEATED_STEERING_WHEEL = "8" ENGINE_CONTROL = "17" REMOTE_REFRESH = "18" REMOTE_IMMOBILIZER = "19" diff --git a/tests/test_heated_steering_wheel.py b/tests/test_heated_steering_wheel.py new file mode 100644 index 0000000..8d3c56d --- /dev/null +++ b/tests/test_heated_steering_wheel.py @@ -0,0 +1,49 @@ +"""Heated steering wheel control (#33). + +Checks the request body against what the iSmart app sends (decrypted MG S6 EV +traffic): request type 8, a single parameter 24 = 1 (on) or 0 (off), and no +end-of-parameters marker. +""" + +from __future__ import annotations + +import base64 +from typing import Any +import unittest + +from saic_ismart_client_ng import SaicApi +from saic_ismart_client_ng.api.vehicle import RvcParamsId, RvcReqType +from saic_ismart_client_ng.crypto_utils import sha256_hex_digest +from saic_ismart_client_ng.model import SaicApiConfiguration + +VIN = "OFFLINE0000000000" + + +class TestHeatedSteeringWheel(unittest.IsolatedAsyncioTestCase): + async def _sent(self, *, enable: bool) -> 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] + await api.control_heated_steering_wheel(VIN, enable=enable) + await api.close() + assert captured["vin"] == VIN + return captured["body"] + + async def test_on(self) -> None: + body = await self._sent(enable=True) + assert body.rvcReqType == RvcReqType.HEATED_STEERING_WHEEL.value + assert body.rvcReqType == "8" + params = [(p.paramId, base64.b64decode(p.paramValue)) for p in body.rvcParams] + assert params == [(RvcParamsId.HEATED_STEERING_WHEEL.value, b"\x01")] + assert params[0][0] == 24 + assert body.vin == sha256_hex_digest(VIN) + + async def test_off(self) -> None: + body = await self._sent(enable=False) + params = [(p.paramId, base64.b64decode(p.paramValue)) for p in body.rvcParams] + assert params == [(24, b"\x00")] From 4f8d87c703be39d04f270839f97dedb7dd5fb84b Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Sun, 25 Oct 2026 15:50:55 +0100 Subject: [PATCH 2/2] docs: say the steering wheel command matches captured traffic --- .../api/vehicle/climate/__init__.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 8e30392..cfda490 100644 --- a/src/saic_ismart_client_ng/api/vehicle/climate/__init__.py +++ b/src/saic_ismart_client_ng/api/vehicle/climate/__init__.py @@ -94,11 +94,12 @@ async def control_heated_steering_wheel( ) -> VehicleControlResp: """Turn the heated steering wheel on or off. - Matches what the iSmart app sends (decrypted MG S6 EV traffic): + Matches the traffic captured from the iSmart app on an MG S6 EV: request type 8 with a single parameter, 24 = 1 (on) or 0 (off), and - no end-of-parameters marker. The car turns the heater off by itself - after about 10 minutes. Its state is reported back in - ``BasicVehicleStatus.steeringHeatLevel``. + no end-of-parameters marker. Other app builds or models may send + more than this; only the S6 EV has been checked. The car turns the + heater off by itself after about 10 minutes. Its state is reported + back in ``BasicVehicleStatus.steeringHeatLevel``. """ rvc_params = [ RvcParams(