From 3050ab1e6df269f1ed5cd175da94f5810fc55488 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 9 Jul 2026 20:46:03 +1000 Subject: [PATCH 1/2] fix(ble): live-verify charging command group over BLE Live-verified snapshot->act->verify->restore->confirm against the test car (plugged in throughout) for the full charging group: charge_start, charge_stop, charge_standard, charge_max_range, set_charge_limit, set_charging_amps, set_scheduled_charging, set_scheduled_departure, add/remove_charge_schedule, add/remove_precondition_schedule, and the two batch_remove_* commands (exercised with all-False, the only safe live case since they're destructive). The car's six real charge schedules and five real precondition schedules were confirmed present and unchanged before and after every schedule test, and the car ended the session restored exactly to its original charge_limit_soc, charge_current_request, and scheduled_charging_mode. charge_port_door_open/close are excluded from live testing per the charge-cable rule (unlatching a plugged-in cable doesn't re-engage without a physical reseat) and ship mocked-transport-only. Documents three real vehicle-side/library-boundary behaviors found live: charge_standard() rejects "already_standard" rather than no-op succeeding; scheduled_charging_mode is a tri-state shared by both set_scheduled_charging and set_scheduled_departure; and set_scheduled_departure's preconditioning_enabled/off_peak_charging_enabled args are accepted but never wired into the proto. Also documents that TeslaFleetError subclasses BaseException, not Exception, which silently breaks naive `except Exception` retry loops around BLE reads. --- AGENTS.md | 5 +- tests/test_ble_charging_commands.py | 354 ++++++++++++++++++++++++++++ 2 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 tests/test_ble_charging_commands.py 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/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, + ) From 89e2fa4192a6ac8b69f22dea4af7d57daa50343b Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 9 Jul 2026 20:56:00 +1000 Subject: [PATCH 2/2] no-mistakes(document): Document BLE charging command behavior --- docs/bluetooth_vehicles.md | 41 +++++++++++++++++++++++ docs/fleet_api_signed_commands.md | 23 +++++++++++++ tesla_fleet_api/tesla/vehicle/commands.py | 26 ++++++++++++-- 3 files changed, 87 insertions(+), 3 deletions(-) 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())