From dabcada9b9399b8acecc4154a978fb8341b1f9de Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 15 Aug 2026 18:32:02 +1000 Subject: [PATCH 1/3] feat(teslemetry): add navigation_waypoints_request REST command Closes the one real gap found auditing TeslemetryVehicle against the live Teslemetry OpenAPI spec: navigation_waypoints_request already had a signed BLE sibling but no REST counterpart. Every other route the audit flagged as missing (52 custom_command/* routes, one energy route, the energy gRPC-envelope family) isn't in the published OpenAPI, so it's out of scope for this library. --- tesla_fleet_api/tesla/vehicle/fleet.py | 8 ++++++++ tests/test_cross_transport_parity.py | 23 +++++++++++++++++++++++ uv.lock | 2 +- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/tesla_fleet_api/tesla/vehicle/fleet.py b/tesla_fleet_api/tesla/vehicle/fleet.py index 98e3dcd..04deeac 100644 --- a/tesla_fleet_api/tesla/vehicle/fleet.py +++ b/tesla_fleet_api/tesla/vehicle/fleet.py @@ -239,6 +239,14 @@ async def navigation_sc_request( json={"id": id, "order": order}, ) + async def navigation_waypoints_request(self, waypoints: str) -> dict[str, Any]: + """Sends a list of waypoints to the vehicle's navigation system.""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/command/navigation_waypoints_request", + json={"waypoints": waypoints}, + ) + async def remote_auto_seat_climate_request( self, auto_seat_position: int | AutoSeat, diff --git a/tests/test_cross_transport_parity.py b/tests/test_cross_transport_parity.py index 349df69..e7c4860 100644 --- a/tests/test_cross_transport_parity.py +++ b/tests/test_cross_transport_parity.py @@ -235,3 +235,26 @@ async def test_note_survives_on_both_transports(self) -> None: await ble.take_drivenote("brake noise up front") action = _sent_vehicle_action(ble, send) self.assertEqual(action.takeDrivenoteAction.note, "brake noise up front") + + +class NavigationWaypointsRequestParityTests(MockedBleTransportTestCase): + """``navigation_waypoints_request`` must carry the same ``waypoints`` + string on both the new REST cloud path and its existing signed-command + BLE sibling.""" + + async def test_waypoints_survive_on_both_transports(self) -> None: + cloud, request = _make_fleet_vehicle(self.VIN) + await cloud.navigation_waypoints_request("some-waypoints-payload") + assert request.await_args is not None + self.assertEqual( + request.await_args.kwargs["json"], + {"waypoints": "some-waypoints-payload"}, + ) + + ble, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + await ble.navigation_waypoints_request("some-waypoints-payload") + action = _sent_vehicle_action(ble, send) + self.assertEqual( + action.navigationWaypointsRequest.waypoints, "some-waypoints-payload" + ) diff --git a/uv.lock b/uv.lock index e11c317..270be5a 100644 --- a/uv.lock +++ b/uv.lock @@ -740,7 +740,7 @@ wheels = [ [[package]] name = "tesla-fleet-api" -version = "1.8.2" +version = "1.9.0" source = { editable = "." } dependencies = [ { name = "aiofiles" }, From baaf374867f7061b5758fd559196bc42fe513e48 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 15 Aug 2026 18:47:49 +1000 Subject: [PATCH 2/3] feat(teslemetry): add custom_command routes per source-verified inventory The published Teslemetry OpenAPI under-publishes custom_command/* - only 10 of 52 routes a source-verified, file:line-cited inventory of Teslemetry's own API repo confirms exist, set_keep_accessory_power_mode among the unpublished ones. Verified this wasn't a fetch artifact (6 independent fetches, all hash-identical, plus a full-text search of the spec) before treating the served spec's gap as a defect to file separately rather than a signal these routes don't exist. Adds the 37 routes the inventory confirms and this class doesn't yet cover, mirroring the field names and semantics of the equivalent BLE Commands methods (same underlying tesla-protocol actions). Excludes 5 routes: 3 the inventory itself flags as unverified/hidden, and 2 that would collide with pre-existing VehicleFleet methods of the same name at a different path. --- tesla_fleet_api/teslemetry/vehicle.py | 338 ++++++++++++++++ ...test_teslemetry_vehicle_custom_commands.py | 375 ++++++++++++++++++ 2 files changed, 713 insertions(+) create mode 100644 tests/test_teslemetry_vehicle_custom_commands.py diff --git a/tesla_fleet_api/teslemetry/vehicle.py b/tesla_fleet_api/teslemetry/vehicle.py index 12359ad..ccd3580 100644 --- a/tesla_fleet_api/teslemetry/vehicle.py +++ b/tesla_fleet_api/teslemetry/vehicle.py @@ -4,9 +4,14 @@ from tesla_fleet_api.const import ( BluetoothConfirmation, + DistanceUnit, + EnergyDisplayFormat, Method, ClosureState, SeatHeaterLevel, + TemperatureUnit, + TimeDisplayFormat, + TirePressureUnit, ) from tesla_fleet_api.tesla.vehicle.vehicles import Vehicles from tesla_fleet_api.tesla.vehicle.fleet import VehicleFleet @@ -287,6 +292,339 @@ async def remove_key(self) -> dict[str, Any]: f"api/1/vehicles/{self.vin}/custom_command/remove_key", ) + async def get_charge_on_solar(self) -> dict[str, Any]: + """Gets the current charge-on-solar feature settings.""" + return await self._request( + Method.GET, + f"api/1/vehicles/{self.vin}/custom_command/charge_on_solar", + ) + + # Below: custom_command routes with no public OpenAPI schema. Body key + # names are inferred from tesla-protocol field names and the confirmed + # set_keep_accessory_power_mode {on: bool} shape, not independently + # verified against Tesla/Teslemetry documentation. + + async def front_zone_light(self, level: int) -> dict[str, Any]: + """Sets front zone light level (0=off, 1=low, 2=med, 3=high).""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/front_zone_light", + json={"level": level}, + ) + + async def rear_zone_light(self, level: int) -> dict[str, Any]: + """Sets rear zone light level (0=off, 1=low, 2=med, 3=high).""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/rear_zone_light", + json={"level": level}, + ) + + async def set_outlets(self, request: int) -> dict[str, Any]: + """Sets outlets on/off (0=off, 1=cabin+bed, 2=cabin).""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/set_outlets", + json={"request": request}, + ) + + async def set_outlet_soc_limit(self, percent: int) -> dict[str, Any]: + """Sets the outlet SOC limit percentage.""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/set_outlet_soc_limit", + json={"percent": percent}, + ) + + async def set_power_feed(self, request: int) -> dict[str, Any]: + """Sets power feed on/off (0=off, 1=feed1, 2=feed2, 3=both).""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/set_power_feed", + json={"request": request}, + ) + + async def set_power_feed_soc_limit(self, percent: int) -> dict[str, Any]: + """Sets the power feed SOC limit percentage.""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/set_power_feed_soc_limit", + json={"percent": percent}, + ) + + async def set_lightbar_brightness(self, brightness: int) -> dict[str, Any]: + """Sets the lightbar brightness.""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/set_lightbar_brightness", + json={"brightness": brightness}, + ) + + async def set_lightbar_middle(self, on: bool) -> dict[str, Any]: + """Enables or disables the lightbar middle light.""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/set_lightbar_middle", + json={"on": on}, + ) + + async def set_lightbar_ditch(self, on: bool) -> dict[str, Any]: + """Enables or disables the ditch lights.""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/set_lightbar_ditch", + json={"on": on}, + ) + + async def set_trailer_light_test(self, on: bool) -> dict[str, Any]: + """Starts or stops the trailer light test.""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/set_trailer_light_test", + json={"on": on}, + ) + + async def set_truck_bed_light_auto(self, on: bool) -> dict[str, Any]: + """Sets truck bed light auto state.""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/set_truck_bed_light_auto", + json={"on": on}, + ) + + async def set_truck_bed_light_brightness(self, brightness: int) -> dict[str, Any]: + """Sets truck bed light brightness.""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/set_truck_bed_light_brightness", + json={"brightness": brightness}, + ) + + async def set_powershare_feature(self, on: bool) -> dict[str, Any]: + """Enables or disables the Powershare feature.""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/set_powershare_feature", + json={"on": on}, + ) + + async def set_powershare_request(self, on: bool) -> dict[str, Any]: + """Enables or disables an active Powershare session.""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/set_powershare_request", + json={"on": on}, + ) + + async def set_powershare_discharge_limit(self, percent: int) -> dict[str, Any]: + """Sets the Powershare discharge limit percentage.""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/set_powershare_discharge_limit", + json={"percent": percent}, + ) + + async def hvac_recirculation(self, on: bool) -> dict[str, Any]: + """Sets HVAC recirculation mode on/off.""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/hvac_recirculation", + json={"on": on}, + ) + + async def set_tent_mode(self, on: bool) -> dict[str, Any]: + """Enables or disables tent mode.""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/set_tent_mode", + json={"on": on}, + ) + + async def set_suspension_level(self, level: int) -> dict[str, Any]: + """Sets the vehicle suspension level (1=entry, 2=low, 3=medium, 4=high, 5=very_high, 6=extract).""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/set_suspension_level", + json={"level": level}, + ) + + async def set_low_power_mode(self, on: bool) -> dict[str, Any]: + """Turns Low Power mode on and off, reducing standby power consumption while the vehicle is parked.""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/set_low_power_mode", + json={"on": on}, + ) + + async def set_keep_accessory_power_mode(self, on: bool) -> dict[str, Any]: + """Turns Keep Accessory Power mode on and off, keeping 12V accessory power available while the vehicle is parked.""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/set_keep_accessory_power_mode", + json={"on": on}, + ) + + async def set_temperature_unit(self, unit: TemperatureUnit | int) -> dict[str, Any]: + """Sets the vehicle's displayed temperature unit.""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/set_temperature_unit", + json={"unit": unit}, + ) + + async def set_distance_unit(self, unit: DistanceUnit | int) -> dict[str, Any]: + """Sets the vehicle's displayed distance unit.""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/set_distance_unit", + json={"unit": unit}, + ) + + async def set_time_display_format( + self, format: TimeDisplayFormat | int + ) -> dict[str, Any]: + """Sets the vehicle's displayed clock format (12h/24h).""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/set_time_display_format", + json={"format": format}, + ) + + async def set_tire_pressure_unit( + self, unit: TirePressureUnit | int + ) -> dict[str, Any]: + """Sets the vehicle's displayed tire pressure unit.""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/set_tire_pressure_unit", + json={"unit": unit}, + ) + + async def set_energy_display_format( + self, format: EnergyDisplayFormat | int + ) -> dict[str, Any]: + """Sets the vehicle's displayed energy/range unit (percentage/distance).""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/set_energy_display_format", + json={"format": format}, + ) + + async def parental_controls(self, activate: bool, pin: str) -> dict[str, Any]: + """Activates or deactivates parental controls with a PIN.""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/parental_controls", + json={"activate": activate, "pin": pin}, + ) + + async def parental_controls_clear_pin(self, pin: str) -> dict[str, Any]: + """Clears the parental controls PIN.""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/parental_controls_clear_pin", + json={"pin": pin}, + ) + + async def parental_controls_clear_pin_admin(self) -> dict[str, Any]: + """Clears the parental controls PIN as admin (fleet manager/owner).""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/parental_controls_clear_pin_admin", + ) + + async def parental_controls_enable_setting( + self, setting: int, enable: bool + ) -> dict[str, Any]: + """Enables or disables a parental controls setting (1=speed_limit, 2=acceleration, 3=safety_features, 4=curfew).""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/parental_controls_enable_setting", + json={"setting": setting, "enable": enable}, + ) + + async def parental_controls_set_speed_limit( + self, limit_mph: float + ) -> dict[str, Any]: + """Sets the parental controls speed limit.""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/parental_controls_set_speed_limit", + json={"limit_mph": limit_mph}, + ) + + async def navigation_gps_destination( + self, lat: float, lon: float, destination: str, order: int + ) -> dict[str, Any]: + """Navigates to coordinates with a named destination string. + + ``order`` is the Tesla remote-nav order integer: 1 replaces the + trip, 2 prepends a stop, and 3 appends a stop. + """ + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/navigation_gps_destination", + json={ + "lat": lat, + "lon": lon, + "destination": destination, + "order": order, + }, + ) + + async def auto_secure_vehicle(self) -> dict[str, Any]: + """Auto-secures the vehicle (locks, closes windows, etc.).""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/auto_secure_vehicle", + ) + + async def cancel_soh_test(self) -> dict[str, Any]: + """Cancels a State of Health test.""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/cancel_soh_test", + ) + + async def get_nearby_charging_sites( + self, + count: int | None = None, + radius: int | None = None, + detail: bool | None = None, + ) -> dict[str, Any]: + """Returns the charging sites near the current location of the vehicle.""" + data: dict[str, Any] = {} + if count is not None: + data["count"] = count + if radius is not None: + data["radius"] = radius + if detail is not None: + data["detail"] = detail + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/get_nearby_charging_sites", + json=data, + ) + + async def get_rate_tariff(self) -> dict[str, Any]: + """Gets the current time-of-use rate tariff schedule.""" + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/get_rate_tariff", + ) + + async def set_rate_tariff(self) -> dict[str, Any]: + """Sets a time-of-use rate tariff schedule. + + Takes no schedule payload - Teslemetry's backend does not forward + one to the vehicle for this route today, so it functions as a + trigger, not a configurable setter, until a real schema is published. + """ + return await self._request( + Method.POST, + f"api/1/vehicles/{self.vin}/custom_command/set_rate_tariff", + ) + class TeslemetryVehicles(Vehicles["Teslemetry"]): """Class containing and creating vehicles.""" diff --git a/tests/test_teslemetry_vehicle_custom_commands.py b/tests/test_teslemetry_vehicle_custom_commands.py new file mode 100644 index 0000000..ebcc820 --- /dev/null +++ b/tests/test_teslemetry_vehicle_custom_commands.py @@ -0,0 +1,375 @@ +"""Tests for the ``TeslemetryVehicle`` ``custom_command/*`` methods added for +command parity with Teslemetry's source-verified route inventory. + +These routes have no public OpenAPI schema, so each test only pins the +request shape (method, path, JSON body) this library sends - not a live +response contract. +""" + +from __future__ import annotations + +from unittest import IsolatedAsyncioTestCase +from unittest.mock import AsyncMock, MagicMock + +from tesla_fleet_api.const import ( + DistanceUnit, + EnergyDisplayFormat, + Method, + TemperatureUnit, + TimeDisplayFormat, + TirePressureUnit, +) +from tesla_fleet_api.teslemetry.vehicle import TeslemetryVehicle + +VIN = "5YJ3E1EA1JF000001" + + +def _make_vehicle() -> tuple[TeslemetryVehicle, AsyncMock]: + parent = MagicMock() + request = AsyncMock(return_value={"response": {"result": True}}) + parent._request = request # pyright: ignore[reportAttributeAccessIssue] + return TeslemetryVehicle(parent, VIN), request + + +class CustomCommandRequestShapeTests(IsolatedAsyncioTestCase): + """Each ``custom_command/*`` method sends the expected method/path/body.""" + + async def test_get_charge_on_solar(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.get_charge_on_solar() + request.assert_awaited_once_with( + Method.GET, f"api/1/vehicles/{VIN}/custom_command/charge_on_solar" + ) + + async def test_front_zone_light(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.front_zone_light(2) + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/front_zone_light", + json={"level": 2}, + ) + + async def test_rear_zone_light(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.rear_zone_light(1) + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/rear_zone_light", + json={"level": 1}, + ) + + async def test_set_outlets(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.set_outlets(1) + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/set_outlets", + json={"request": 1}, + ) + + async def test_set_outlet_soc_limit(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.set_outlet_soc_limit(80) + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/set_outlet_soc_limit", + json={"percent": 80}, + ) + + async def test_set_power_feed(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.set_power_feed(3) + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/set_power_feed", + json={"request": 3}, + ) + + async def test_set_power_feed_soc_limit(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.set_power_feed_soc_limit(90) + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/set_power_feed_soc_limit", + json={"percent": 90}, + ) + + async def test_set_lightbar_brightness(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.set_lightbar_brightness(50) + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/set_lightbar_brightness", + json={"brightness": 50}, + ) + + async def test_set_lightbar_middle(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.set_lightbar_middle(True) + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/set_lightbar_middle", + json={"on": True}, + ) + + async def test_set_lightbar_ditch(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.set_lightbar_ditch(False) + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/set_lightbar_ditch", + json={"on": False}, + ) + + async def test_set_trailer_light_test(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.set_trailer_light_test(True) + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/set_trailer_light_test", + json={"on": True}, + ) + + async def test_set_truck_bed_light_auto(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.set_truck_bed_light_auto(True) + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/set_truck_bed_light_auto", + json={"on": True}, + ) + + async def test_set_truck_bed_light_brightness(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.set_truck_bed_light_brightness(75) + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/set_truck_bed_light_brightness", + json={"brightness": 75}, + ) + + async def test_set_powershare_feature(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.set_powershare_feature(True) + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/set_powershare_feature", + json={"on": True}, + ) + + async def test_set_powershare_request(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.set_powershare_request(True) + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/set_powershare_request", + json={"on": True}, + ) + + async def test_set_powershare_discharge_limit(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.set_powershare_discharge_limit(20) + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/set_powershare_discharge_limit", + json={"percent": 20}, + ) + + async def test_hvac_recirculation(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.hvac_recirculation(True) + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/hvac_recirculation", + json={"on": True}, + ) + + async def test_set_tent_mode(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.set_tent_mode(True) + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/set_tent_mode", + json={"on": True}, + ) + + async def test_set_suspension_level(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.set_suspension_level(4) + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/set_suspension_level", + json={"level": 4}, + ) + + async def test_set_low_power_mode(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.set_low_power_mode(True) + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/set_low_power_mode", + json={"on": True}, + ) + + async def test_set_keep_accessory_power_mode(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.set_keep_accessory_power_mode(True) + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/set_keep_accessory_power_mode", + json={"on": True}, + ) + + async def test_set_temperature_unit(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.set_temperature_unit(TemperatureUnit.CELSIUS) + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/set_temperature_unit", + json={"unit": TemperatureUnit.CELSIUS}, + ) + + async def test_set_distance_unit(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.set_distance_unit(DistanceUnit.KILOMETERS) + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/set_distance_unit", + json={"unit": DistanceUnit.KILOMETERS}, + ) + + async def test_set_time_display_format(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.set_time_display_format(TimeDisplayFormat.HOUR_24) + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/set_time_display_format", + json={"format": TimeDisplayFormat.HOUR_24}, + ) + + async def test_set_tire_pressure_unit(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.set_tire_pressure_unit(TirePressureUnit.BAR) + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/set_tire_pressure_unit", + json={"unit": TirePressureUnit.BAR}, + ) + + async def test_set_energy_display_format(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.set_energy_display_format(EnergyDisplayFormat.DISTANCE) + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/set_energy_display_format", + json={"format": EnergyDisplayFormat.DISTANCE}, + ) + + async def test_parental_controls(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.parental_controls(True, "1234") + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/parental_controls", + json={"activate": True, "pin": "1234"}, + ) + + async def test_parental_controls_clear_pin(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.parental_controls_clear_pin("1234") + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/parental_controls_clear_pin", + json={"pin": "1234"}, + ) + + async def test_parental_controls_clear_pin_admin(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.parental_controls_clear_pin_admin() + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/parental_controls_clear_pin_admin", + ) + + async def test_parental_controls_enable_setting(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.parental_controls_enable_setting(1, True) + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/parental_controls_enable_setting", + json={"setting": 1, "enable": True}, + ) + + async def test_parental_controls_set_speed_limit(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.parental_controls_set_speed_limit(65.0) + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/parental_controls_set_speed_limit", + json={"limit_mph": 65.0}, + ) + + async def test_navigation_gps_destination(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.navigation_gps_destination(37.3230, -122.0322, "Tesla HQ", 1) + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/navigation_gps_destination", + json={ + "lat": 37.3230, + "lon": -122.0322, + "destination": "Tesla HQ", + "order": 1, + }, + ) + + async def test_auto_secure_vehicle(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.auto_secure_vehicle() + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/auto_secure_vehicle", + ) + + async def test_cancel_soh_test(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.cancel_soh_test() + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/cancel_soh_test", + ) + + async def test_get_nearby_charging_sites_omits_unset_fields(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.get_nearby_charging_sites() + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/get_nearby_charging_sites", + json={}, + ) + + async def test_get_nearby_charging_sites_with_params(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.get_nearby_charging_sites(count=5, radius=25, detail=True) + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/get_nearby_charging_sites", + json={"count": 5, "radius": 25, "detail": True}, + ) + + async def test_get_rate_tariff(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.get_rate_tariff() + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/get_rate_tariff", + ) + + async def test_set_rate_tariff(self) -> None: + vehicle, request = _make_vehicle() + await vehicle.set_rate_tariff() + request.assert_awaited_once_with( + Method.POST, + f"api/1/vehicles/{VIN}/custom_command/set_rate_tariff", + ) From 18880ff1a8a08c3923c4ba55de316de4bc062359 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 15 Aug 2026 19:35:32 +1000 Subject: [PATCH 3/3] fix(teslemetry): align custom_command method names with BLE Commands siblings VehicleRouter dispatches by exact attribute name across backends, so a name or signature mismatch between the BLE and Teslemetry implementations of the same command silently breaks failover instead of raising a clear error. Renames the new (unpublished) Teslemetry side to match its BLE Commands sibling in every case: front_zone_light -> set_front_zone_lights, rear_zone_light -> set_rear_zone_lights, hvac_recirculation -> set_recirculation, navigation_gps_destination -> navigation_gps_destination_request. set_rate_tariff is instead renamed to trigger_rate_tariff_update, since the BLE side already has a published set_rate_tariff(seasons, tariff) with a required nested schedule - keeping that name here with no arguments would raise TypeError on a failed-over call. REST paths are unchanged. --- tesla_fleet_api/teslemetry/vehicle.py | 44 +++++++++++++------ ...test_teslemetry_vehicle_custom_commands.py | 22 +++++----- 2 files changed, 43 insertions(+), 23 deletions(-) diff --git a/tesla_fleet_api/teslemetry/vehicle.py b/tesla_fleet_api/teslemetry/vehicle.py index ccd3580..5250e08 100644 --- a/tesla_fleet_api/teslemetry/vehicle.py +++ b/tesla_fleet_api/teslemetry/vehicle.py @@ -304,16 +304,24 @@ async def get_charge_on_solar(self) -> dict[str, Any]: # set_keep_accessory_power_mode {on: bool} shape, not independently # verified against Tesla/Teslemetry documentation. - async def front_zone_light(self, level: int) -> dict[str, Any]: - """Sets front zone light level (0=off, 1=low, 2=med, 3=high).""" + async def set_front_zone_lights(self, level: int) -> dict[str, Any]: + """Sets front zone light level (0=off, 1=low, 2=med, 3=high). + + Named to match the BLE ``Commands.set_front_zone_lights`` sibling so + ``VehicleRouter`` failover can find this method by attribute name. + """ return await self._request( Method.POST, f"api/1/vehicles/{self.vin}/custom_command/front_zone_light", json={"level": level}, ) - async def rear_zone_light(self, level: int) -> dict[str, Any]: - """Sets rear zone light level (0=off, 1=low, 2=med, 3=high).""" + async def set_rear_zone_lights(self, level: int) -> dict[str, Any]: + """Sets rear zone light level (0=off, 1=low, 2=med, 3=high). + + Named to match the BLE ``Commands.set_rear_zone_lights`` sibling so + ``VehicleRouter`` failover can find this method by attribute name. + """ return await self._request( Method.POST, f"api/1/vehicles/{self.vin}/custom_command/rear_zone_light", @@ -424,8 +432,12 @@ async def set_powershare_discharge_limit(self, percent: int) -> dict[str, Any]: json={"percent": percent}, ) - async def hvac_recirculation(self, on: bool) -> dict[str, Any]: - """Sets HVAC recirculation mode on/off.""" + async def set_recirculation(self, on: bool) -> dict[str, Any]: + """Sets HVAC recirculation mode on/off. + + Named to match the BLE ``Commands.set_recirculation`` sibling so + ``VehicleRouter`` failover can find this method by attribute name. + """ return await self._request( Method.POST, f"api/1/vehicles/{self.vin}/custom_command/hvac_recirculation", @@ -553,13 +565,15 @@ async def parental_controls_set_speed_limit( json={"limit_mph": limit_mph}, ) - async def navigation_gps_destination( + async def navigation_gps_destination_request( self, lat: float, lon: float, destination: str, order: int ) -> dict[str, Any]: """Navigates to coordinates with a named destination string. ``order`` is the Tesla remote-nav order integer: 1 replaces the - trip, 2 prepends a stop, and 3 appends a stop. + trip, 2 prepends a stop, and 3 appends a stop. Named to match the + BLE ``Commands.navigation_gps_destination_request`` sibling so + ``VehicleRouter`` failover can find this method by attribute name. """ return await self._request( Method.POST, @@ -613,12 +627,16 @@ async def get_rate_tariff(self) -> dict[str, Any]: f"api/1/vehicles/{self.vin}/custom_command/get_rate_tariff", ) - async def set_rate_tariff(self) -> dict[str, Any]: - """Sets a time-of-use rate tariff schedule. + async def trigger_rate_tariff_update(self) -> dict[str, Any]: + """Triggers a time-of-use rate tariff update with no schedule payload. - Takes no schedule payload - Teslemetry's backend does not forward - one to the vehicle for this route today, so it functions as a - trigger, not a configurable setter, until a real schema is published. + Teslemetry's backend does not forward a schedule to the vehicle for + this route today, so it functions as a trigger, not a configurable + setter, until a real schema is published. Deliberately not named + ``set_rate_tariff`` - the BLE ``Commands.set_rate_tariff`` sibling + takes a required ``seasons``/``tariff`` schedule, and a same-named, + payload-free method here would raise ``TypeError`` if ``VehicleRouter`` + failover forwarded those arguments to it. """ return await self._request( Method.POST, diff --git a/tests/test_teslemetry_vehicle_custom_commands.py b/tests/test_teslemetry_vehicle_custom_commands.py index ebcc820..4d42fa3 100644 --- a/tests/test_teslemetry_vehicle_custom_commands.py +++ b/tests/test_teslemetry_vehicle_custom_commands.py @@ -41,18 +41,18 @@ async def test_get_charge_on_solar(self) -> None: Method.GET, f"api/1/vehicles/{VIN}/custom_command/charge_on_solar" ) - async def test_front_zone_light(self) -> None: + async def test_set_front_zone_lights(self) -> None: vehicle, request = _make_vehicle() - await vehicle.front_zone_light(2) + await vehicle.set_front_zone_lights(2) request.assert_awaited_once_with( Method.POST, f"api/1/vehicles/{VIN}/custom_command/front_zone_light", json={"level": 2}, ) - async def test_rear_zone_light(self) -> None: + async def test_set_rear_zone_lights(self) -> None: vehicle, request = _make_vehicle() - await vehicle.rear_zone_light(1) + await vehicle.set_rear_zone_lights(1) request.assert_awaited_once_with( Method.POST, f"api/1/vehicles/{VIN}/custom_command/rear_zone_light", @@ -176,9 +176,9 @@ async def test_set_powershare_discharge_limit(self) -> None: json={"percent": 20}, ) - async def test_hvac_recirculation(self) -> None: + async def test_set_recirculation(self) -> None: vehicle, request = _make_vehicle() - await vehicle.hvac_recirculation(True) + await vehicle.set_recirculation(True) request.assert_awaited_once_with( Method.POST, f"api/1/vehicles/{VIN}/custom_command/hvac_recirculation", @@ -310,9 +310,11 @@ async def test_parental_controls_set_speed_limit(self) -> None: json={"limit_mph": 65.0}, ) - async def test_navigation_gps_destination(self) -> None: + async def test_navigation_gps_destination_request(self) -> None: vehicle, request = _make_vehicle() - await vehicle.navigation_gps_destination(37.3230, -122.0322, "Tesla HQ", 1) + await vehicle.navigation_gps_destination_request( + 37.3230, -122.0322, "Tesla HQ", 1 + ) request.assert_awaited_once_with( Method.POST, f"api/1/vehicles/{VIN}/custom_command/navigation_gps_destination", @@ -366,9 +368,9 @@ async def test_get_rate_tariff(self) -> None: f"api/1/vehicles/{VIN}/custom_command/get_rate_tariff", ) - async def test_set_rate_tariff(self) -> None: + async def test_trigger_rate_tariff_update(self) -> None: vehicle, request = _make_vehicle() - await vehicle.set_rate_tariff() + await vehicle.trigger_rate_tariff_update() request.assert_awaited_once_with( Method.POST, f"api/1/vehicles/{VIN}/custom_command/set_rate_tariff",