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
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
* door windows: close, ventilate (open a few centimetres) or fully open
* start rear window heating
* stop rear window heating
* remote climate
Expand Down
36 changes: 34 additions & 2 deletions src/saic_ismart_client_ng/api/vehicle/windows/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@
VehicleControlReq,
VehicleControlResp,
)
from saic_ismart_client_ng.api.vehicle.windows.schema import VehicleWindowId
from saic_ismart_client_ng.api.vehicle.windows.schema import (
DoorWindowsAction,
VehicleWindowId,
)
from saic_ismart_client_ng.crypto_utils import sha256_hex_digest

__all__ = ["VehicleWindowId"]
__all__ = ["DoorWindowsAction", "VehicleWindowId"]


class SaicVehicleWindowsApi(SaicVehicleApi):
Expand Down Expand Up @@ -57,3 +60,32 @@ async def control_windows(
)

return await self.send_vehicle_control_command(request, vin)

async def control_door_windows(
self, vin: str, *, action: DoorWindowsAction
) -> VehicleControlResp:
"""Close, ventilate or fully open all four door windows together.

Matches what the iSmart app sends (decrypted MG S6 EV traffic): the
sunroof parameter set to 0 (left alone), all four door windows set to
1, and the action (0 close, 1 ventilate, 2 fully open). There is no
end-of-parameters marker. The MG S6 EV doesn't accept single-window
control this way.

``control_windows(should_open=True)`` sends 3 as its open value, which
the MG S6 EV does not use; it is left unchanged for other models.
"""
rvc_params = [
RvcParams(RvcParamsId.WINDOW_SUNROOF, b"\x00"),
RvcParams(RvcParamsId.WINDOW_DRIVER, b"\x01"),
RvcParams(RvcParamsId.WINDOW_2, b"\x01"),
RvcParams(RvcParamsId.WINDOW_3, b"\x01"),
RvcParams(RvcParamsId.WINDOW_4, b"\x01"),
RvcParams(RvcParamsId.WINDOW_OPEN_CLOSE, action.value.to_bytes(1, "big")),
]
request = VehicleControlReq(
rvc_req_type=RvcReqType.WINDOWS,
rvc_params=rvc_params,
vin=sha256_hex_digest(vin),
)
return await self.send_vehicle_control_command(request, vin)
14 changes: 14 additions & 0 deletions src/saic_ismart_client_ng/api/vehicle/windows/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,17 @@ class VehicleWindowId(Enum):
WINDOW_2 = RvcParamsId.WINDOW_2
WINDOW_3 = RvcParamsId.WINDOW_3
WINDOW_4 = RvcParamsId.WINDOW_4


class DoorWindowsAction(Enum):
"""What to do with all four door windows at once.

The values are what the iSmart app sends (decrypted MG S6 EV traffic).
Other models may handle 1 and 2 differently; only the MG S6 EV has been
checked. The car reports ventilated and fully open windows the same way in its
status, so the two can't be told apart afterwards.
"""

CLOSE = 0
VENTILATE = 1 # open a few centimetres (only confirmed on the MG S6 EV)
OPEN = 2 # fully open
64 changes: 64 additions & 0 deletions tests/test_door_windows.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Window ventilation: close, ventilate or fully open all door windows (#36).

Checks the request body against what the iSmart app sends (decrypted MG S6 EV
traffic): request type 3, the sunroof parameter 0 (left alone), all four door
windows 1, then the action (0 close, 1 ventilate, 2 fully open), with 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 RvcReqType
from saic_ismart_client_ng.api.vehicle.windows import DoorWindowsAction
from saic_ismart_client_ng.crypto_utils import sha256_hex_digest
from saic_ismart_client_ng.model import SaicApiConfiguration

VIN = "OFFLINE0000000000"


class TestDoorWindows(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_each_action(self) -> None:
for action, value in (
(DoorWindowsAction.CLOSE, 0),
(DoorWindowsAction.VENTILATE, 1),
(DoorWindowsAction.OPEN, 2),
):
with self.subTest(action=action):
body = await self._sent("control_door_windows", action=action)
assert body.rvcReqType == RvcReqType.WINDOWS.value == "3"
assert self._params(body) == [
(8, b"\x00"), # sunroof left alone
(9, b"\x01"),
(10, b"\x01"),
(11, b"\x01"),
(12, b"\x01"),
(13, bytes([value])),
]
assert body.vin == sha256_hex_digest(VIN)

async def test_control_windows_unchanged(self) -> None:
# The existing helper keeps its open value (3) for other models.
body = await self._sent("control_windows", should_open=True, windows=[])
assert self._params(body)[-1] == (13, b"\x03")
Loading