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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
26 changes: 15 additions & 11 deletions tesla_fleet_api/tesla/vehicle/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,6 +27,7 @@

from tesla_fleet_api.const import (
LOGGER,
AutoSeat,
Trunk,
ClimateKeeperMode,
CabinOverheatProtectionTemp,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -994,21 +990,29 @@ 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(
autoSeatClimateAction=AutoSeatClimateAction(
carseat=[
AutoSeatClimateAction.CarSeat(
on=auto_climate_on,
seat_position=AutoSeatClimatePositions[
auto_seat_position
],
seat_position=cast(
"AutoSeatClimateAction.AutoSeatPosition_E",
auto_seat_position,
),
)
]
)
Expand Down
9 changes: 7 additions & 2 deletions tesla_fleet_api/tesla/vehicle/fleet.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import TYPE_CHECKING, Any, Generic, List, TypeVar

from tesla_fleet_api.const import (
AutoSeat,
CabinOverheatProtectionTemp,
ClimateKeeperMode,
Level,
Expand Down Expand Up @@ -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",
Expand Down
105 changes: 105 additions & 0 deletions tests/test_auto_seat_climate.py
Original file line number Diff line number Diff line change
@@ -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,
)
Loading