From c48ba43db936595119ba42bc8a4b35f1e3223262 Mon Sep 17 00:00:00 2001 From: townsmcp <14061617+townsmcp@users.noreply.github.com> Date: Sat, 7 Nov 2026 22:22:32 +0000 Subject: [PATCH] feat: open and close a power tailgate (control_tailgate) control_tailgate(vin, action=TailgateAction.OPEN/CLOSE) sends what the iSmart app sends on an MG S6 EV (decrypted app traffic, UK): request type 9 with a single parameter, 31 = 1 (open) or 2 (close), and no end-of-parameters marker. On that car both were accepted, the tailgate moved, and bootStatus then read 1 and 0. open_tailgate (unlock of lock 2, "Unlock Tailgate" in the app) is unchanged. --- README.md | 1 + .../api/vehicle/locks/__init__.py | 35 ++++++++++- .../api/vehicle/locks/schema.py | 10 ++++ .../api/vehicle/schema.py | 2 + tests/test_power_tailgate.py | 58 +++++++++++++++++++ 5 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 tests/test_power_tailgate.py diff --git a/README.md b/README.md index e357e04..f00a3d3 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Supported functionality (partial list, check the code for more) * get charging status * lock vehicle * unlock vehicle +* unlock the tailgate (releases the latch), or open and close a power tailgate * door windows: close, ventilate (open a few centimetres) or fully open * start rear window heating * stop rear window heating diff --git a/src/saic_ismart_client_ng/api/vehicle/locks/__init__.py b/src/saic_ismart_client_ng/api/vehicle/locks/__init__.py index befd5cc..195d525 100644 --- a/src/saic_ismart_client_ng/api/vehicle/locks/__init__.py +++ b/src/saic_ismart_client_ng/api/vehicle/locks/__init__.py @@ -1,7 +1,10 @@ from __future__ import annotations from saic_ismart_client_ng.api.vehicle import SaicVehicleApi -from saic_ismart_client_ng.api.vehicle.locks.schema import VehicleLockId +from saic_ismart_client_ng.api.vehicle.locks.schema import ( + TailgateAction, + VehicleLockId, +) from saic_ismart_client_ng.api.vehicle.schema import ( RvcParams, RvcParamsId, @@ -12,7 +15,7 @@ from saic_ismart_client_ng.crypto_utils import sha256_hex_digest from saic_ismart_client_ng.exceptions import SaicApiException -__all__ = ["VehicleLockId"] +__all__ = ["TailgateAction", "VehicleLockId"] class SaicVehicleLocksApi(SaicVehicleApi): @@ -25,10 +28,38 @@ async def unlock_vehicle(self, vin: str) -> VehicleControlResp: ) async def open_tailgate(self, vin: str) -> VehicleControlResp: + """Unlock the tailgate (lock 2), which releases its latch. + + The iSmart app calls this "Unlock Tailgate". On a car with a power + tailgate, ``control_tailgate`` opens and closes it instead. + """ return await self.control_vehicle_locks( vin, should_lock=False, lock_id=VehicleLockId.TAILGATE ) + async def control_tailgate( + self, vin: str, *, action: TailgateAction + ) -> VehicleControlResp: + """Open or close a power tailgate. + + Matches what the iSmart app sends (decrypted MG S6 EV traffic, UK): + request type 9 with a single parameter, 31 = 1 (open) or 2 (close), + and no end-of-parameters marker. On that car both were accepted, the + tailgate moved, and ``BasicVehicleStatus.bootStatus`` then read 1 + (open) and 0 (closed). Only the S6 EV has been checked; cars without a + power tailgate are expected to refuse it. + """ + command = VehicleControlReq( + rvc_req_type=RvcReqType.TAILGATE, + rvc_params=[ + RvcParams( + RvcParamsId.TAILGATE, action.value.to_bytes(1, byteorder="big") + ) + ], + vin=sha256_hex_digest(vin), + ) + return await self.send_vehicle_control_command(command, vin) + async def control_vehicle_locks( self, vin: str, diff --git a/src/saic_ismart_client_ng/api/vehicle/locks/schema.py b/src/saic_ismart_client_ng/api/vehicle/locks/schema.py index 4f84051..cb6b9bb 100644 --- a/src/saic_ismart_client_ng/api/vehicle/locks/schema.py +++ b/src/saic_ismart_client_ng/api/vehicle/locks/schema.py @@ -6,3 +6,13 @@ class VehicleLockId(Enum): TAILGATE = 2 DOORS = 3 + + +class TailgateAction(Enum): + """Open or close a power tailgate. + + The values are what the iSmart app sends (decrypted MG S6 EV traffic, UK). + """ + + OPEN = 1 + CLOSE = 2 diff --git a/src/saic_ismart_client_ng/api/vehicle/schema.py b/src/saic_ismart_client_ng/api/vehicle/schema.py index 742207a..aa9979a 100644 --- a/src/saic_ismart_client_ng/api/vehicle/schema.py +++ b/src/saic_ismart_client_ng/api/vehicle/schema.py @@ -188,6 +188,7 @@ class RvcParamsId(Enum): HEATED_STEERING_WHEEL = 24 HEATED_SEAT_REAR_LEFT = 25 HEATED_SEAT_REAR_RIGHT = 26 + TAILGATE = 31 PARAMS_MAX = 0xFF @@ -211,6 +212,7 @@ class RvcReqType(Enum): CLIMATE = "6" AIR_CLEAN = "7" HEATED_STEERING_WHEEL = "8" + TAILGATE = "9" ENGINE_CONTROL = "17" REMOTE_REFRESH = "18" REMOTE_IMMOBILIZER = "19" diff --git a/tests/test_power_tailgate.py b/tests/test_power_tailgate.py new file mode 100644 index 0000000..3252088 --- /dev/null +++ b/tests/test_power_tailgate.py @@ -0,0 +1,58 @@ +"""Power tailgate: open and close (control_tailgate). + +Checks the request body against what the iSmart app sends (decrypted MG S6 EV +traffic, UK): request type 9 with a single parameter, 31 = 1 to open or 2 to +close, and no end-of-parameters marker. open_tailgate is unchanged. +""" + +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 RvcReqType +from saic_ismart_client_ng.api.vehicle.locks import TailgateAction +from saic_ismart_client_ng.crypto_utils import sha256_hex_digest +from saic_ismart_client_ng.model import SaicApiConfiguration + +VIN = "OFFLINE0000000000" + + +class TestPowerTailgate(unittest.IsolatedAsyncioTestCase): + async def _sent(self, call: str, **kwargs: Any) -> 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 getattr(api, call)(VIN, **kwargs) + await api.close() + return captured["body"] + + @staticmethod + def _params(body: Any) -> list[tuple[int, bytes]]: + return [(p.paramId, base64.b64decode(p.paramValue)) for p in body.rvcParams] + + async def test_open_and_close(self) -> None: + for action, value in ((TailgateAction.OPEN, 1), (TailgateAction.CLOSE, 2)): + with self.subTest(action=action): + body = await self._sent("control_tailgate", action=action) + assert body.rvcReqType == RvcReqType.TAILGATE.value == "9" + assert self._params(body) == [(31, bytes([value]))] + assert body.vin == sha256_hex_digest(VIN) + + async def test_open_tailgate_unchanged(self) -> None: + body = await self._sent("open_tailgate") + assert body.rvcReqType == RvcReqType.OPEN_LOCKS.value == "2" + assert self._params(body) == [ + (4, b"\x00"), + (5, b"\x00"), + (6, b"\x00"), + (7, b"\x02"), + (255, b"\x00\x00\x00\x00"), + ]