diff --git a/AGENTS.md b/AGENTS.md index 2ebfe58..1608d22 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,4 +97,5 @@ Generated protobuf files live in `tesla/vehicle/proto/` and are excluded from ru - **Linting**: ruff (proto files excluded). - **Async**: All API methods are `async`. Uses `aiohttp` for HTTP, `aiofiles` for file I/O, `bleak` for BLE. - **Enums**: Custom `StrEnum`/`IntEnum` in `const.py` (not stdlib). `Region` is a `Literal["na", "eu", "cn"]`, not an enum. +- **Seat indexing gotcha**: two distinct seat enums with different conventions. `Seat` is **0-indexed** (`FRONT_LEFT=0`) and is for the manual seat heater/cooler paths (`remote_seat_heater_request`, `remote_seat_cooler_request`). `AutoSeat` is **1-indexed** (`FRONT_LEFT=1`, `FRONT_RIGHT=2`) and is the correct type for `remote_auto_seat_climate_request` on **both** backends — its values equal Tesla's REST wire values and the proto `AutoSeatPosition_*` enum. Don't mix them; passing a `Seat` to the auto-climate command is off-by-one (issue #11). - **Naming**: camelCase for class instance attributes that mirror API structure (`energySites`, `createFleet`). Snake_case for method names that are API endpoints. diff --git a/tesla_fleet_api/tesla/vehicle/commands.py b/tesla_fleet_api/tesla/vehicle/commands.py index 7c5f7a1..2026d2f 100644 --- a/tesla_fleet_api/tesla/vehicle/commands.py +++ b/tesla_fleet_api/tesla/vehicle/commands.py @@ -3,7 +3,7 @@ import struct from random import randbytes -from typing import Any, TYPE_CHECKING, ClassVar, Generic, Literal, TypeVar +from typing import Any, TYPE_CHECKING, ClassVar, Generic, Literal, TypeVar, cast import time import hmac import hashlib @@ -27,6 +27,7 @@ from tesla_fleet_api.const import ( LOGGER, + AutoSeat, Trunk, ClimateKeeperMode, CabinOverheatProtectionTemp, @@ -191,11 +192,6 @@ CommandParentT = TypeVar("CommandParentT", bound="Tesla") # ENUMs to convert ints to proto typed ints -AutoSeatClimatePositions = ( - AutoSeatClimateAction.AutoSeatPosition_FrontLeft, - AutoSeatClimateAction.AutoSeatPosition_FrontRight, -) - HvacSeatCoolerLevels = ( HvacSeatCoolerActions.HvacSeatCoolerLevel_Off, HvacSeatCoolerActions.HvacSeatCoolerLevel_Low, @@ -994,11 +990,18 @@ async def navigation_waypoints_request(self, waypoints: str) -> dict[str, Any]: ) async def remote_auto_seat_climate_request( - self, auto_seat_position: int, auto_climate_on: bool + self, auto_seat_position: int | AutoSeat, auto_climate_on: bool ) -> dict[str, Any]: - """Sets automatic seat heating and cooling.""" + """Sets automatic seat heating and cooling. + + ``auto_seat_position`` is 1-indexed (``AutoSeat.FRONT_LEFT`` == 1), + matching Tesla's wire values and the proto ``AutoSeatPosition_*`` enum. + """ # AutoSeatPosition_FrontLeft = 1; # AutoSeatPosition_FrontRight = 2; + # AutoSeat's 1-indexed values equal the proto AutoSeatPosition_* values, + # so the int maps directly onto the proto enum (protobuf accepts the + # raw int for enum fields). return await self._sendInfotainment( Action( vehicleAction=VehicleAction( @@ -1006,9 +1009,10 @@ async def remote_auto_seat_climate_request( carseat=[ AutoSeatClimateAction.CarSeat( on=auto_climate_on, - seat_position=AutoSeatClimatePositions[ - auto_seat_position - ], + seat_position=cast( + "AutoSeatClimateAction.AutoSeatPosition_E", + auto_seat_position, + ), ) ] ) diff --git a/tesla_fleet_api/tesla/vehicle/fleet.py b/tesla_fleet_api/tesla/vehicle/fleet.py index d55c7c8..b3a3cde 100644 --- a/tesla_fleet_api/tesla/vehicle/fleet.py +++ b/tesla_fleet_api/tesla/vehicle/fleet.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Any, Generic, List, TypeVar from tesla_fleet_api.const import ( + AutoSeat, CabinOverheatProtectionTemp, ClimateKeeperMode, Level, @@ -235,10 +236,14 @@ async def navigation_sc_request( async def remote_auto_seat_climate_request( self, - auto_seat_position: int | Seat, + auto_seat_position: int | AutoSeat, auto_climate_on: bool, ) -> dict[str, Any]: - """Sets automatic seat heating and cooling.""" + """Sets automatic seat heating and cooling. + + ``auto_seat_position`` is 1-indexed (``AutoSeat.FRONT_LEFT`` == 1), + matching Tesla's wire values for this endpoint. + """ return await self._request( Method.POST, f"api/1/vehicles/{self.vin}/command/remote_auto_seat_climate_request", diff --git a/tests/test_auto_seat_climate.py b/tests/test_auto_seat_climate.py new file mode 100644 index 0000000..b07349d --- /dev/null +++ b/tests/test_auto_seat_climate.py @@ -0,0 +1,105 @@ +"""Regression tests for issue #11 — auto seat climate seat indexing. + +The auto-seat-climate command is 1-indexed (``AutoSeat.FRONT_LEFT`` == 1), +matching Tesla's wire values and the proto ``AutoSeatPosition_*`` enum. Both the +Fleet REST path and the signed/protobuf path must agree on that convention for +the same input. +""" + +from unittest import IsolatedAsyncioTestCase +from unittest.mock import AsyncMock, MagicMock + +from cryptography.hazmat.primitives.asymmetric import ec + +from tesla_fleet_api.const import AutoSeat, Method +from tesla_fleet_api.tesla.vehicle.commands import Commands +from tesla_fleet_api.tesla.vehicle.fleet import VehicleFleet +from tesla_fleet_api.tesla.vehicle.proto.car_server_pb2 import ( + Action, + AutoSeatClimateAction, +) + +VIN = "5YJXCAE43LF123456" + + +class _TestCommands(Commands): + """Concrete ``Commands`` subclass so the ABC can be instantiated in tests.""" + + _auth_method = "hmac" + + async def _send(self, msg, requires): # pragma: no cover - never invoked + raise NotImplementedError + + +class AutoSeatClimateFleetTests(IsolatedAsyncioTestCase): + """The Fleet REST path sends the 1-indexed seat position verbatim.""" + + def create_vehicle(self) -> tuple[VehicleFleet, AsyncMock]: + parent = MagicMock() + request = AsyncMock(return_value={"response": {"result": True}}) + parent._request = request # pyright: ignore[reportAttributeAccessIssue] + return VehicleFleet(parent, VIN), request + + async def test_front_left_sends_index_one(self) -> None: + vehicle, request = self.create_vehicle() + + await vehicle.remote_auto_seat_climate_request(AutoSeat.FRONT_LEFT, True) + + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/command/remote_auto_seat_climate_request", + json={"auto_seat_position": 1, "auto_climate_on": True}, + ) + + async def test_front_right_sends_index_two(self) -> None: + vehicle, request = self.create_vehicle() + + await vehicle.remote_auto_seat_climate_request(AutoSeat.FRONT_RIGHT, False) + + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/command/remote_auto_seat_climate_request", + json={"auto_seat_position": 2, "auto_climate_on": False}, + ) + + +class AutoSeatClimateSignedTests(IsolatedAsyncioTestCase): + """The signed/protobuf path maps the same 1-indexed input onto the proto enum.""" + + def create_vehicle(self) -> tuple[_TestCommands, AsyncMock]: + parent = MagicMock() + parent.private_key = ec.generate_private_key(ec.SECP256R1()) + vehicle = _TestCommands(parent, VIN) + send = AsyncMock(return_value={"response": {"result": True}}) + vehicle._sendInfotainment = send # pyright: ignore[reportAttributeAccessIssue] + return vehicle, send + + def _seat_position(self, send: AsyncMock) -> int: + action: Action = send.await_args.args[0] + carseats = action.vehicleAction.autoSeatClimateAction.carseat + self.assertEqual(len(carseats), 1) + return carseats[0].seat_position + + async def test_front_left_emits_front_left_proto(self) -> None: + vehicle, send = self.create_vehicle() + + await vehicle.remote_auto_seat_climate_request(AutoSeat.FRONT_LEFT, True) + + self.assertEqual( + self._seat_position(send), + AutoSeatClimateAction.AutoSeatPosition_FrontLeft, + ) + self.assertTrue( + send.await_args.args[0].vehicleAction.autoSeatClimateAction.carseat[0].on + ) + + async def test_front_right_emits_front_right_proto(self) -> None: + vehicle, send = self.create_vehicle() + + # AutoSeat.FRONT_RIGHT == 2 must no longer raise (previously IndexError). + await vehicle.remote_auto_seat_climate_request(AutoSeat.FRONT_RIGHT, False) + + self.assertEqual( + self._seat_position(send), + AutoSeatClimateAction.AutoSeatPosition_FrontRight, + )