diff --git a/AGENTS.md b/AGENTS.md index 6f3ccc6..332cfef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,7 +99,7 @@ No release-please or version-bump automation. To ship: bump `version` in `pyproj `exceptions.py` maps HTTP status codes and error keys to specific exception classes. `raise_for_status()` parses responses and raises the appropriate exception. Signed command faults have separate hierarchies: `TeslaFleetInformationFault`, `TeslaFleetMessageFault`, `SignedMessageInformationFault`, `WhitelistOperationStatus`. -All exceptions inherit from `TeslaFleetError(BaseException)`. +All exceptions inherit from `TeslaFleetError(BaseException)`, deliberately **not** `Exception` — a bare `except Exception` (e.g. in retry/backoff loops around BLE reads) silently fails to catch `BluetoothTimeout` and every other library error. Catch `TeslaFleetError` (or `BaseException`) explicitly. ### Protobuf @@ -122,6 +122,9 @@ Source protobuf definitions live in `proto/`; generated Python files live in `te - **BLE individual-door powered-close gotcha**: `open_*_door()` unlatches a door over VCSEC; on a Model 3 there is no reliable powered close. Live-verified: `close_rear_passenger_door()` returned an OK ack (`{"result": True}`) but `closures_state().door_open_passenger_rear` stayed `True` - the ack only means the car accepted the command, not that the door physically re-latched (a human has to push it shut). Never chain an automated snapshot→act→verify→restore cycle across an individual door-open command; treat the 8 door commands as ack-verified only, or require a human to confirm the physical re-close before trusting `closures_state()` again. - **`remote_heater_control_enabled` gate**: `climate_state().remote_heater_control_enabled` is a read-only vehicle-side setting (no command exists to flip it) that gates every "remote comfort" action - live-verified on `commands.py`: `remote_seat_heater_request`, `remote_auto_seat_climate_request`, `remote_steering_wheel_heater_request`, `remote_steering_wheel_heat_level_request`, `remote_auto_steering_wheel_heat_climate_request`. With it `false`, the vehicle ACKs `{"result": false, "reason": "cabin comfort remote settings not enabled"}` and leaves state untouched (not a library bug, not a partial mutation) - this is presumably the touchscreen/app "Remote Climate" or comfort-access toggle, outside this library's command surface. Check this field before treating a comfort-command rejection as a regression. - **Protobuf oneof-by-string-kwargs bypasses pyright**: `remote_seat_heater_request`/`remote_seat_cooler_request` (`commands.py`) build their `HvacSeatHeaterAction`/`HvacSeatCoolerAction` via a `dict` of literal field-name strings expanded as `**kwargs` into the message constructor (with a `# pyright: ignore[reportUnknownArgumentType]` already on the call) - a typo in one of those strings (e.g. `SEAT_HEATER_MEDIUM` vs. the proto's actual `SEAT_HEATER_MED`, fixed live during PR-4) raises at call time, not at type-check time. Cross-check any new field-name string against the proto (`proto/car_server.proto`) rather than trusting pyright to catch it. +- **`scheduled_charging_mode` is tri-state and shared**: `set_scheduled_charging` and `set_scheduled_departure` (`commands.py`) both write the same `ChargeState.scheduled_charging_mode` (Off/StartAt/DepartBy) - live-verified. Disabling one when the other is active turns the whole feature Off rather than leaving the other's config intact; a caller toggling one must read `charge_state()` first and restore the exact prior mode (including the other command's fields) rather than assuming independence. +- **`set_scheduled_departure`'s `preconditioning_enabled`/`off_peak_charging_enabled` args are dead**: live-verified - `ScheduledDepartureAction` (`proto/car_server.proto`) has no fields for them, only `preconditioning_times`/`off_peak_charging_times` (weekday-recurrence only, no on/off). Passing `preconditioning_enabled=False` has no effect on the vehicle's observed state. Not a library bug to "fix" without a wider protocol capability; document, don't rely on these args to gate the feature. +- **`charge_standard()` rejects `already_standard`**: live-verified - calling it while `charge_state().charge_limit_soc` already equals `charge_limit_soc_std` gets `{"result": False, "reason": "already_standard"}` rather than a no-op success. Not a library bug; callers/tests exercising this command need the limit to actually differ from the std preset first (e.g. via `charge_max_range()` or `set_charge_limit()`). ## Maintaining this file diff --git a/docs/bluetooth_vehicles.md b/docs/bluetooth_vehicles.md index ab9c95b..ebbb558 100644 --- a/docs/bluetooth_vehicles.md +++ b/docs/bluetooth_vehicles.md @@ -97,6 +97,47 @@ with `cabin comfort remote settings not enabled` when `climate_state().remote_heater_control_enabled` is false. That field is a read-only vehicle setting; the library has no command to enable it. +## Charging Commands + +`VehicleBluetooth` inherits the signed-command charging surface. The following +commands send over BLE: + +- `charge_start()` +- `charge_stop()` +- `charge_standard()` +- `charge_max_range()` +- `charge_port_door_open()` +- `charge_port_door_close()` +- `set_charge_limit(percent)` +- `set_charging_amps(charging_amps)` +- `set_scheduled_charging(enable, time)` +- `set_scheduled_departure(...)` +- `add_charge_schedule(...)` +- `remove_charge_schedule(id)` +- `batch_remove_charge_schedules(home, work, other)` +- `add_precondition_schedule(...)` +- `remove_precondition_schedule(id)` +- `batch_remove_precondition_schedules(home, work, other)` + +`set_scheduled_charging()` and `set_scheduled_departure()` both update the +vehicle's shared `scheduled_charging_mode`. Disabling one mode while the other +is active can turn scheduled charging/departure off entirely, so callers that +toggle either setting should read `charge_state()` first and restore the prior +mode and fields when preserving the other schedule matters. + +For signed commands, `set_scheduled_departure()` sends `enable`, +`departure_time`, weekday/all-week recurrence choices, and +`end_off_peak_time`. Its `preconditioning_enabled` and +`off_peak_charging_enabled` arguments are accepted for API compatibility but do +not map to fields in the vehicle's signed-command protobuf. + +`charge_standard()` is not treated as a no-op by all vehicles: if the current +charge limit already equals `charge_limit_soc_std`, the vehicle may reject the +command with `already_standard`. + +Avoid actuating `charge_port_door_open()` or `charge_port_door_close()` against +a plugged-in vehicle unless someone can reseat the cable if needed. + ## Open/Close Individual Doors (Bluetooth Only) The individual door closure commands are Bluetooth-only and are not available via Fleet API signed commands. diff --git a/docs/fleet_api_signed_commands.md b/docs/fleet_api_signed_commands.md index ce5da6b..c91e125 100644 --- a/docs/fleet_api_signed_commands.md +++ b/docs/fleet_api_signed_commands.md @@ -155,6 +155,29 @@ async def main(): asyncio.run(main()) ``` +## Other Charging Commands + +Signed commands also include `charge_standard()`, `charge_max_range()`, +`set_charging_amps(charging_amps)`, schedule configuration, and charge-port +door commands. These methods share the same signatures as the Fleet API vehicle +commands, but are sent through the signed-command protocol. + +`set_scheduled_charging()` and `set_scheduled_departure()` both update the +vehicle's shared `scheduled_charging_mode`. Disabling one mode while the other +is active can turn scheduled charging/departure off entirely, so callers that +toggle either setting should read the current `charge_state()` first and +restore the prior mode and fields when preserving the other schedule matters. + +For signed commands, `set_scheduled_departure()` sends `enable`, +`departure_time`, weekday/all-week recurrence choices, and +`end_off_peak_time`. Its `preconditioning_enabled` and +`off_peak_charging_enabled` arguments are accepted for API compatibility but do +not map to fields in the vehicle's signed-command protobuf. + +`charge_standard()` is not treated as a no-op by all vehicles: if the current +charge limit already equals `charge_limit_soc_std`, the vehicle may reject the +command with `already_standard`. + ## Flash Lights You can flash the lights of a specific vehicle using its VIN: diff --git a/tesla_fleet_api/tesla/vehicle/commands.py b/tesla_fleet_api/tesla/vehicle/commands.py index 4efe986..42ffaec 100644 --- a/tesla_fleet_api/tesla/vehicle/commands.py +++ b/tesla_fleet_api/tesla/vehicle/commands.py @@ -794,7 +794,11 @@ async def charge_port_door_open(self) -> dict[str, Any]: ) async def charge_standard(self) -> dict[str, Any]: - """Charges in Standard mode.""" + """Charges in Standard mode. + + Some vehicles reject this command with ``already_standard`` when the + current charge limit already equals ``charge_limit_soc_std``. + """ return await self._sendInfotainment( Action( vehicleAction=VehicleAction( @@ -1366,7 +1370,13 @@ async def set_preconditioning_max( ) async def set_scheduled_charging(self, enable: bool, time: int) -> dict[str, Any]: - """Sets a time at which charging should be completed. The time parameter is minutes after midnight (e.g: time=120 schedules charging for 2:00am vehicle local time).""" + """Sets the scheduled charging start time. + + ``time`` is minutes after midnight in vehicle-local time. This command + and ``set_scheduled_departure`` both update the vehicle's shared + ``scheduled_charging_mode``; disabling one while the other is active can + turn scheduling off entirely. + """ return await self._sendInfotainment( Action( vehicleAction=VehicleAction( @@ -1387,7 +1397,17 @@ async def set_scheduled_departure( off_peak_charging_weekdays_only: bool = False, end_off_peak_time: int = 0, ) -> dict[str, Any]: - """Sets a time at which departure should be completed. The time parameter is minutes after midnight (e.g: time=120 schedules departure for 2:00am vehicle local time).""" + """Sets the scheduled departure time. + + ``departure_time`` and ``end_off_peak_time`` are minutes after midnight + in vehicle-local time. This command and ``set_scheduled_charging`` both + update the vehicle's shared ``scheduled_charging_mode``; disabling one + while the other is active can turn scheduling off entirely. + + ``preconditioning_enabled`` and ``off_peak_charging_enabled`` are + accepted by the Python signature for compatibility but the signed + command protobuf has no fields for them, so they are not sent. + """ if preconditioning_weekdays_only: preconditioning_times = PreconditioningTimes(weekdays=Void()) diff --git a/tests/test_ble_charging_commands.py b/tests/test_ble_charging_commands.py new file mode 100644 index 0000000..bcd51c2 --- /dev/null +++ b/tests/test_ble_charging_commands.py @@ -0,0 +1,354 @@ +"""Charging command group (PR-5) over the mocked BLE transport. + +Live-verified against the test car (VIN LRW3F7EK4NC716336, m5-btproxy, +plugged in throughout) per-command status: + +- Fully live-verified snapshot->act->verify->restore->confirm, in a single + BLE session: ``set_charge_limit``, ``charge_max_range``, ``charge_stop``, + ``charge_start``, ``charge_standard``, ``set_charging_amps``, + ``set_scheduled_charging``, ``set_scheduled_departure``, + ``add_charge_schedule``, ``remove_charge_schedule``, + ``add_precondition_schedule``, ``remove_precondition_schedule``. + ``batch_remove_charge_schedules``/``batch_remove_precondition_schedules`` + were live-exercised with ``home=work=other=False`` (the only safe live + case - removes nothing in any category) to prove ACK + wire path without + touching the car's real schedules; the six real charge schedules and five + real precondition schedules present on the car were confirmed untouched + before and after every schedule test. The car was confirmed restored to + its exact original charge_limit_soc, charge_current_request, + scheduled_charging_mode, and schedule-id sets at the end of the session. +- Live-discovered vehicle-side behavior (not a library bug): calling + ``charge_standard()`` while the current limit already equals + ``charge_limit_soc_std`` is cleanly rejected by the car + (``{"result": False, "reason": "already_standard"}``) rather than treated + as a no-op success - the test below exercises the accepted path. +- Live-discovered: ``set_scheduled_departure()``'s ``preconditioning_enabled`` + and ``off_peak_charging_enabled`` parameters are accepted by the Python + signature but never wired into ``ScheduledDepartureAction`` (that proto + message has no such fields, see ``proto/car_server.proto``) - passing + ``preconditioning_enabled=False`` live had no effect on the vehicle's + observed ``ChargeState.preconditioning_enabled``. Documented in AGENTS.md. +- ``charge_port_door_open``/``charge_port_door_close`` are EXCLUDED from live + testing (CAPTAIN-PRESENT-ONLY - unlatching a plugged-in cable does not + re-engage without a physical reseat) and ship mocked-transport-only below. +""" + +from tesla_fleet_api.tesla.vehicle.proto.car_server_pb2 import Action +from tesla_fleet_api.tesla.vehicle.proto.common_pb2 import ( + OffPeakChargingTimes, + PreconditioningTimes, +) +from tesla_fleet_api.tesla.vehicle.proto.universal_message_pb2 import Domain +from tesla_fleet_api.tesla.vehicle.proto.vcsec_pb2 import ( + ClosureMoveType_E, + UnsignedMessage, +) + +from ble_mocked_transport import ( + MockedBleTransportTestCase, + decrypt_sent_command, + infotainment_action_ok_reply, + vcsec_ok_reply, +) + + +def _decode_vehicle_action(vehicle, sent_msg): + plaintext = decrypt_sent_command(vehicle, sent_msg) + action = Action.FromString(plaintext) + assert sent_msg.to_destination.domain == Domain.DOMAIN_INFOTAINMENT + assert sent_msg.signature_data.HasField("AES_GCM_Personalized_data") + return action.vehicleAction + + +class ChargeStartStopTests(MockedBleTransportTestCase): + async def test_charge_start_sends_start_void(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + result = await vehicle.charge_start() + + self.assertEqual(result, {"response": {"result": True, "reason": ""}}) + action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + self.assertTrue(action.chargingStartStopAction.HasField("start")) + + async def test_charge_stop_sends_stop_void(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + await vehicle.charge_stop() + + action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + self.assertTrue(action.chargingStartStopAction.HasField("stop")) + + +class ChargeStandardMaxRangeTests(MockedBleTransportTestCase): + async def test_charge_standard_sends_start_standard_void(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + await vehicle.charge_standard() + + action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + self.assertTrue(action.chargingStartStopAction.HasField("start_standard")) + + async def test_charge_max_range_sends_start_max_range_void(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + await vehicle.charge_max_range() + + action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + self.assertTrue(action.chargingStartStopAction.HasField("start_max_range")) + + +class SetChargeLimitTests(MockedBleTransportTestCase): + async def test_sends_charge_limit_percent(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + result = await vehicle.set_charge_limit(75) + + self.assertEqual(result, {"response": {"result": True, "reason": ""}}) + action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + self.assertEqual(action.chargingSetLimitAction.percent, 75) + + +class SetChargingAmpsTests(MockedBleTransportTestCase): + async def test_sends_charging_amps(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + await vehicle.set_charging_amps(16) + + action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + self.assertEqual(action.setChargingAmpsAction.charging_amps, 16) + + +class SetScheduledChargingTests(MockedBleTransportTestCase): + async def test_sends_enabled_and_charging_time(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + await vehicle.set_scheduled_charging(True, 120) + + action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + scheduled = action.scheduledChargingAction + self.assertTrue(scheduled.enabled) + self.assertEqual(scheduled.charging_time, 120) + + async def test_disable_sends_enabled_false(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + await vehicle.set_scheduled_charging(False, 0) + + action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + self.assertFalse(action.scheduledChargingAction.enabled) + + +class SetScheduledDepartureTests(MockedBleTransportTestCase): + """``preconditioning_enabled``/``off_peak_charging_enabled`` are accepted by + the Python signature but not wired into ``ScheduledDepartureAction`` - live- + verified (see module docstring). This asserts the actual wire behavior: + only ``enabled``/``departure_time``/the two *_times recurrence messages/ + ``off_peak_hours_end_time`` reach the proto.""" + + async def test_sends_departure_time_and_recurrence_windows(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + await vehicle.set_scheduled_departure( + enable=True, + preconditioning_enabled=False, + preconditioning_weekdays_only=True, + departure_time=90, + off_peak_charging_enabled=False, + off_peak_charging_weekdays_only=True, + end_off_peak_time=360, + ) + + action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + departure = action.scheduledDepartureAction + self.assertTrue(departure.enabled) + self.assertEqual(departure.departure_time, 90) + self.assertEqual(departure.off_peak_hours_end_time, 360) + self.assertEqual( + departure.preconditioning_times.WhichOneof("times"), "weekdays" + ) + self.assertEqual( + departure.off_peak_charging_times.WhichOneof("times"), "weekdays" + ) + # No field on ScheduledDepartureAction carries preconditioning_enabled + # or off_peak_charging_enabled - proving they cannot be wired. + self.assertNotIn( + "preconditioning_enabled", [f.name for f in departure.DESCRIPTOR.fields] + ) + self.assertNotIn( + "off_peak_charging_enabled", [f.name for f in departure.DESCRIPTOR.fields] + ) + + async def test_all_week_when_not_weekdays_only(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + await vehicle.set_scheduled_departure(departure_time=0) + + action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + departure = action.scheduledDepartureAction + self.assertEqual( + departure.preconditioning_times, + PreconditioningTimes(all_week=departure.preconditioning_times.all_week), + ) + self.assertEqual( + departure.off_peak_charging_times, + OffPeakChargingTimes(all_week=departure.off_peak_charging_times.all_week), + ) + + +class ChargeScheduleTests(MockedBleTransportTestCase): + async def test_add_charge_schedule_sends_schedule_fields(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + await vehicle.add_charge_schedule( + days_of_week=64, + enabled=True, + lat=1.5, + lon=2.5, + start_time=60, + end_time=120, + one_time=True, + id=42, + name="test", + ) + + action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + schedule = action.addChargeScheduleAction + self.assertEqual(schedule.days_of_week, 64) + self.assertTrue(schedule.enabled) + self.assertAlmostEqual(schedule.latitude, 1.5) + self.assertAlmostEqual(schedule.longitude, 2.5) + self.assertEqual(schedule.start_time, 60) + self.assertEqual(schedule.end_time, 120) + self.assertTrue(schedule.one_time) + self.assertEqual(schedule.id, 42) + self.assertEqual(schedule.name, "test") + + async def test_add_charge_schedule_requires_start_or_end_time(self) -> None: + vehicle, _send = self.make_vehicle() + + with self.assertRaises(ValueError): + await vehicle.add_charge_schedule( + days_of_week=1, enabled=True, lat=0.0, lon=0.0 + ) + + async def test_remove_charge_schedule_sends_id(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + await vehicle.remove_charge_schedule(7) + + action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + self.assertEqual(action.removeChargeScheduleAction.id, 7) + + async def test_batch_remove_charge_schedules_sends_location_flags(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + await vehicle.batch_remove_charge_schedules(home=True, work=False, other=True) + + action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + batch = action.batchRemoveChargeSchedulesAction + self.assertTrue(batch.home) + self.assertFalse(batch.work) + self.assertTrue(batch.other) + + +class PreconditionScheduleTests(MockedBleTransportTestCase): + async def test_add_precondition_schedule_sends_schedule_fields(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + await vehicle.add_precondition_schedule( + days_of_week=1, + enabled=True, + lat=3.5, + lon=4.5, + precondition_time=585, + id=99, + one_time=False, + name="precon-test", + ) + + action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + schedule = action.addPreconditionScheduleAction + self.assertEqual(schedule.days_of_week, 1) + self.assertTrue(schedule.enabled) + self.assertAlmostEqual(schedule.latitude, 3.5) + self.assertAlmostEqual(schedule.longitude, 4.5) + self.assertEqual(schedule.precondition_time, 585) + self.assertEqual(schedule.id, 99) + self.assertFalse(schedule.one_time) + self.assertEqual(schedule.name, "precon-test") + + async def test_remove_precondition_schedule_sends_id(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + await vehicle.remove_precondition_schedule(11) + + action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + self.assertEqual(action.removePreconditionScheduleAction.id, 11) + + async def test_batch_remove_precondition_schedules_sends_location_flags( + self, + ) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + await vehicle.batch_remove_precondition_schedules( + home=False, work=True, other=False + ) + + action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + batch = action.batchRemovePreconditionSchedulesAction + self.assertFalse(batch.home) + self.assertTrue(batch.work) + self.assertFalse(batch.other) + + +class ChargePortDoorTests(MockedBleTransportTestCase): + """CAPTAIN-PRESENT-ONLY (unlatching a plugged-in cable does not re-engage + without a physical reseat) - mocked-transport proto construction only, + never live-actuated. See decisions-resolved.md.""" + + async def test_charge_port_door_open_sends_rke_open(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = vcsec_ok_reply() + + result = await vehicle.charge_port_door_open() + + self.assertEqual(result, {"response": {"result": True, "reason": ""}}) + send.assert_awaited_once() + sent_msg = send.await_args.args[0] + self.assertEqual(sent_msg.to_destination.domain, Domain.DOMAIN_VEHICLE_SECURITY) + plaintext = decrypt_sent_command(vehicle, sent_msg) + unsigned = UnsignedMessage.FromString(plaintext) + self.assertEqual( + unsigned.closureMoveRequest.chargePort, + ClosureMoveType_E.CLOSURE_MOVE_TYPE_OPEN, + ) + + async def test_charge_port_door_close_sends_rke_close(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = vcsec_ok_reply() + + await vehicle.charge_port_door_close() + + sent_msg = send.await_args.args[0] + plaintext = decrypt_sent_command(vehicle, sent_msg) + unsigned = UnsignedMessage.FromString(plaintext) + self.assertEqual( + unsigned.closureMoveRequest.chargePort, + ClosureMoveType_E.CLOSURE_MOVE_TYPE_CLOSE, + )