Skip to content
Open
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 @@ -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
Expand Down
35 changes: 33 additions & 2 deletions src/saic_ismart_client_ng/api/vehicle/locks/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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):
Expand All @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions src/saic_ismart_client_ng/api/vehicle/locks/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions src/saic_ismart_client_ng/api/vehicle/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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"
Expand Down
58 changes: 58 additions & 0 deletions tests/test_power_tailgate.py
Original file line number Diff line number Diff line change
@@ -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"),
]
Loading