diff --git a/README.md b/README.md index 1bfdab2..009957f 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 +* door windows: close, ventilate (open a few centimetres) or fully open * start rear window heating * stop rear window heating * remote climate diff --git a/src/saic_ismart_client_ng/api/vehicle/windows/__init__.py b/src/saic_ismart_client_ng/api/vehicle/windows/__init__.py index dba55f0..d7b6211 100644 --- a/src/saic_ismart_client_ng/api/vehicle/windows/__init__.py +++ b/src/saic_ismart_client_ng/api/vehicle/windows/__init__.py @@ -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): @@ -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) diff --git a/src/saic_ismart_client_ng/api/vehicle/windows/schema.py b/src/saic_ismart_client_ng/api/vehicle/windows/schema.py index bf24cbd..2e1dc01 100644 --- a/src/saic_ismart_client_ng/api/vehicle/windows/schema.py +++ b/src/saic_ismart_client_ng/api/vehicle/windows/schema.py @@ -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 diff --git a/tests/test_door_windows.py b/tests/test_door_windows.py new file mode 100644 index 0000000..fac1e4e --- /dev/null +++ b/tests/test_door_windows.py @@ -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")