diff --git a/AGENTS.md b/AGENTS.md index 59a8e8b..6f3ccc6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,6 +120,8 @@ Source protobuf definitions live in `proto/`; generated Python files live in `te - **BLE infotainment boot-delay gotcha**: `wake_up()` (VCSEC) returns as soon as the vehicle-security computer acks it, well before the infotainment computer is ready to complete a signed-command handshake. An INFO-domain read/command issued immediately after `wake_up()` can raise `BluetoothTimeout` on the handshake through no fault of the command itself. Live-verified: waiting ~10s after `wake_up()` before the first INFO read is sufficient on the test rig; callers doing INFO work right after waking should retry-with-backoff rather than treat one timeout as failure. - **BLE `vehicle_data()` response-size cap**: the vehicle's signed-command implementation enforces its own response-size limit independent of the BLE transport's packet reassembly. Live-verified: a single-endpoint `vehicle_data()` call (or any of the dedicated per-substate readers like `charge_state()`) succeeds, but requesting as few as two `BluetoothVehicleData` endpoints together reliably raises `TeslaFleetMessageFaultResponseSizeExceedsMTU` (`exceptions.py`). This is why `vehicle_data()`'s `endpoints` arg has no all-endpoints default (unlike the cloud method) - prefer the per-substate readers, or a single-endpoint `vehicle_data()` call, over a multi-endpoint composite. Auto-chunking (split under the cap, merge replies) would fix this properly but is not implemented. - **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. ## Maintaining this file diff --git a/docs/bluetooth_vehicles.md b/docs/bluetooth_vehicles.md index b0538c1..ab9c95b 100644 --- a/docs/bluetooth_vehicles.md +++ b/docs/bluetooth_vehicles.md @@ -82,6 +82,21 @@ request. The infotainment computer can take longer to become ready, so INFO-domain reads immediately after waking, such as `charge_state()` or `vehicle_data()`, should retry `BluetoothTimeout` with backoff. +## Climate Commands + +Bluetooth vehicles support the same signed climate command methods as +`VehicleSigned`, including `auto_conditioning_start()`, +`auto_conditioning_stop()`, `set_temps()`, `set_climate_keeper_mode()`, +`set_cabin_overheat_protection()`, `set_cop_temp()`, +`set_bioweapon_mode()`, `set_preconditioning_max()`, +`set_recirculation()`, the remote seat heater/cooler methods, and the +remote steering-wheel heat methods. + +Remote seat and steering-wheel comfort commands can be rejected by the vehicle +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. + ## 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 606b591..ce5da6b 100644 --- a/docs/fleet_api_signed_commands.md +++ b/docs/fleet_api_signed_commands.md @@ -245,6 +245,20 @@ async def main(): asyncio.run(main()) ``` +## Climate Commands + +`VehicleSigned` supports signed climate command methods including +`auto_conditioning_start()`, `auto_conditioning_stop()`, `set_temps()`, +`set_climate_keeper_mode()`, `set_cabin_overheat_protection()`, +`set_cop_temp()`, `set_bioweapon_mode()`, `set_preconditioning_max()`, +`set_recirculation()`, the remote seat heater/cooler methods, and the +remote steering-wheel heat methods. + +Remote seat and steering-wheel comfort commands can be rejected by the vehicle +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. + ## Low Power / Keep Accessory Power Modes These are signed-only commands with no Fleet API REST equivalent. `set_low_power_mode` reduces standby power consumption while the vehicle is parked, and `set_keep_accessory_power_mode` keeps 12V accessory power available while parked. Both take a single `bool` to turn the mode on or off: diff --git a/tesla_fleet_api/tesla/vehicle/commands.py b/tesla_fleet_api/tesla/vehicle/commands.py index b65a852..4efe986 100644 --- a/tesla_fleet_api/tesla/vehicle/commands.py +++ b/tesla_fleet_api/tesla/vehicle/commands.py @@ -999,6 +999,8 @@ async def remote_auto_seat_climate_request( ``auto_seat_position`` is 1-indexed (``AutoSeat.FRONT_LEFT`` == 1), matching Tesla's wire values and the proto ``AutoSeatPosition_*`` enum. + The vehicle can reject remote comfort commands when + ``climate_state().remote_heater_control_enabled`` is false. """ # AutoSeatPosition_FrontLeft = 1; # AutoSeatPosition_FrontRight = 2; @@ -1026,7 +1028,11 @@ async def remote_auto_seat_climate_request( async def remote_auto_steering_wheel_heat_climate_request( self, on: bool ) -> dict[str, Any]: - """Sets automatic steering wheel heating on/off.""" + """Sets automatic steering wheel heating on/off. + + The vehicle can reject remote comfort commands when + ``climate_state().remote_heater_control_enabled`` is false. + """ return await self._sendInfotainment( Action( vehicleAction=VehicleAction( @@ -1084,12 +1090,16 @@ async def remote_seat_cooler_request( async def remote_seat_heater_request( self, seat_position: int, seat_heater_level: int ) -> dict[str, Any]: - """Sets seat heating.""" - # HvacSeatCoolerLevel_Unknown = 0; - # HvacSeatCoolerLevel_Off = 1; - # HvacSeatCoolerLevel_Low = 2; - # HvacSeatCoolerLevel_Med = 3; - # HvacSeatCoolerLevel_High = 4; + """Sets seat heating. + + The vehicle can reject remote comfort commands when + ``climate_state().remote_heater_control_enabled`` is false. + """ + # Void SEAT_HEATER_UNKNOWN = 1; + # Void SEAT_HEATER_OFF = 2; + # Void SEAT_HEATER_LOW = 3; + # Void SEAT_HEATER_MED = 4; + # Void SEAT_HEATER_HIGH = 5; # Void CAR_SEAT_UNKNOWN = 6; # Void CAR_SEAT_FRONT_LEFT = 7; # Void CAR_SEAT_FRONT_RIGHT = 8; @@ -1129,7 +1139,7 @@ async def remote_seat_heater_request( case 1: heater_action_dict["SEAT_HEATER_LOW"] = Void() case 2: - heater_action_dict["SEAT_HEATER_MEDIUM"] = Void() + heater_action_dict["SEAT_HEATER_MED"] = Void() case 3: heater_action_dict["SEAT_HEATER_HIGH"] = Void() case _: @@ -1155,7 +1165,11 @@ async def remote_start_drive(self) -> dict[str, Any]: async def remote_steering_wheel_heat_level_request( self, level: int ) -> dict[str, Any]: - """Sets steering wheel heat level.""" + """Sets steering wheel heat level. + + The vehicle can reject remote comfort commands when + ``climate_state().remote_heater_control_enabled`` is false. + """ return await self._sendInfotainment( Action( vehicleAction=VehicleAction( @@ -1167,7 +1181,12 @@ async def remote_steering_wheel_heat_level_request( ) async def remote_steering_wheel_heater_request(self, on: bool) -> dict[str, Any]: - """Sets steering wheel heating on/off. For vehicles that do not support auto steering wheel heat.""" + """Sets steering wheel heating on/off. + + For vehicles that do not support auto steering wheel heat. The vehicle + can reject remote comfort commands when + ``climate_state().remote_heater_control_enabled`` is false. + """ return await self._sendInfotainment( Action( vehicleAction=VehicleAction( diff --git a/tesla_fleet_api/tesla/vehicle/fleet.py b/tesla_fleet_api/tesla/vehicle/fleet.py index b3a3cde..877ea8e 100644 --- a/tesla_fleet_api/tesla/vehicle/fleet.py +++ b/tesla_fleet_api/tesla/vehicle/fleet.py @@ -243,6 +243,8 @@ async def remote_auto_seat_climate_request( ``auto_seat_position`` is 1-indexed (``AutoSeat.FRONT_LEFT`` == 1), matching Tesla's wire values for this endpoint. + The vehicle can reject remote comfort commands when + ``climate_state().remote_heater_control_enabled`` is false. """ return await self._request( Method.POST, @@ -256,7 +258,11 @@ async def remote_auto_seat_climate_request( async def remote_auto_steering_wheel_heat_climate_request( self, on: bool ) -> dict[str, Any]: - """Sets automatic steering wheel heating on/off.""" + """Sets automatic steering wheel heating on/off. + + The vehicle can reject remote comfort commands when + ``climate_state().remote_heater_control_enabled`` is false. + """ return await self._request( Method.POST, f"api/1/vehicles/{self.vin}/command/remote_auto_steering_wheel_heat_climate_request", @@ -291,7 +297,11 @@ async def remote_seat_heater_request( seat_position: Seat | int, seat_heater_level: Level | int, ) -> dict[str, Any]: - """Sets seat heating.""" + """Sets seat heating. + + The vehicle can reject remote comfort commands when + ``climate_state().remote_heater_control_enabled`` is false. + """ return await self._request( Method.POST, f"api/1/vehicles/{self.vin}/command/remote_seat_heater_request", @@ -310,7 +320,11 @@ async def remote_start_drive(self) -> dict[str, Any]: async def remote_steering_wheel_heat_level_request( self, level: Level | int ) -> dict[str, Any]: - """Sets steering wheel heat level.""" + """Sets steering wheel heat level. + + The vehicle can reject remote comfort commands when + ``climate_state().remote_heater_control_enabled`` is false. + """ return await self._request( Method.POST, f"api/1/vehicles/{self.vin}/command/remote_steering_wheel_heat_level_request", @@ -318,7 +332,12 @@ async def remote_steering_wheel_heat_level_request( ) async def remote_steering_wheel_heater_request(self, on: bool) -> dict[str, Any]: - """Sets steering wheel heating on/off. For vehicles that do not support auto steering wheel heat.""" + """Sets steering wheel heating on/off. + + For vehicles that do not support auto steering wheel heat. The vehicle + can reject remote comfort commands when + ``climate_state().remote_heater_control_enabled`` is false. + """ return await self._request( Method.POST, f"api/1/vehicles/{self.vin}/command/remote_steering_wheel_heater_request", diff --git a/tests/test_ble_climate_commands.py b/tests/test_ble_climate_commands.py new file mode 100644 index 0000000..66aecd8 --- /dev/null +++ b/tests/test_ble_climate_commands.py @@ -0,0 +1,332 @@ +"""Climate command group (PR-4) over the mocked BLE transport. + +Live-verified against the test car (VIN LRW3F7EK4NC716336, m5-btproxy) +per-command status: + +- Fully live-verified snapshot->act->verify->restore->confirm: + ``auto_conditioning_start``, ``auto_conditioning_stop``, ``set_temps``, + ``set_climate_keeper_mode``, ``set_cabin_overheat_protection``. +- Live-attempted, cleanly rejected by the vehicle (ACK decodes correctly, + state confirmed unchanged - not a library bug, see AGENTS.md): + ``set_cop_temp`` (``not_supported``); ``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`` (all: ``cabin comfort + remote settings not enabled`` - the ``remote_heater_control_enabled`` gate). +- Deferred, mocked-transport only: ``set_bioweapon_mode`` and + ``remote_seat_cooler_request`` (no HEPA filter / no seat coolers on this + Model 3 - the relevant ``ClimateState`` fields are absent from every live + read); ``set_preconditioning_max`` and ``set_recirculation`` (no + ``ClimateState`` field reflects either the preconditioning override flag or + recirculation mode - not reliably snapshot/verify/restore-able over BLE). +""" + +from tesla_fleet_api.const import AutoSeat +from tesla_fleet_api.tesla.vehicle.proto.car_server_pb2 import ( + Action, + ActionStatus, + HvacSeatCoolerActions, + OperationStatus_E as InfoOperationStatus_E, + Response, + ResultReason, +) +from tesla_fleet_api.tesla.vehicle.proto.common_pb2 import StwHeatLevel +from tesla_fleet_api.tesla.vehicle.proto.universal_message_pb2 import ( + Destination, + Domain, + RoutableMessage, +) +from tesla_fleet_api.tesla.vehicle.proto.vehicle_pb2 import ClimateState + +from ble_mocked_transport import ( + MockedBleTransportTestCase, + decrypt_sent_command, + infotainment_action_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 + + +def _rejection_reply(reason: str) -> RoutableMessage: + body = Response( + actionStatus=ActionStatus( + result=InfoOperationStatus_E.OPERATIONSTATUS_ERROR, + result_reason=ResultReason(plain_text=reason), + ) + ) + return RoutableMessage( + from_destination=Destination(domain=Domain.DOMAIN_INFOTAINMENT), + protobuf_message_as_bytes=body.SerializeToString(), + ) + + +class AutoConditioningTests(MockedBleTransportTestCase): + async def test_auto_conditioning_start_sends_hvac_auto_on(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + result = await vehicle.auto_conditioning_start() + + self.assertEqual(result, {"response": {"result": True, "reason": ""}}) + vehicle_action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + self.assertTrue(vehicle_action.hvacAutoAction.power_on) + + async def test_auto_conditioning_stop_sends_hvac_auto_off(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + await vehicle.auto_conditioning_stop() + + vehicle_action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + self.assertFalse(vehicle_action.hvacAutoAction.power_on) + + +class SetTempsTests(MockedBleTransportTestCase): + async def test_sends_driver_and_passenger_temps(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + result = await vehicle.set_temps(21.5, 19.0) + + self.assertEqual(result, {"response": {"result": True, "reason": ""}}) + vehicle_action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + hvac = vehicle_action.hvacTemperatureAdjustmentAction + self.assertAlmostEqual(hvac.driver_temp_celsius, 21.5) + self.assertAlmostEqual(hvac.passenger_temp_celsius, 19.0) + + +class SetClimateKeeperModeTests(MockedBleTransportTestCase): + async def test_keep_mode_sends_climate_keeper_on(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + await vehicle.set_climate_keeper_mode(1) + + vehicle_action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + keeper = vehicle_action.hvacClimateKeeperAction + self.assertEqual( + keeper.ClimateKeeperAction, + keeper.ClimateKeeperAction_On, + ) + + +class SetCabinOverheatProtectionTests(MockedBleTransportTestCase): + async def test_fan_only_sends_on_and_fan_only(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + await vehicle.set_cabin_overheat_protection(True, True) + + vehicle_action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + cop = vehicle_action.setCabinOverheatProtectionAction + self.assertTrue(cop.on) + self.assertTrue(cop.fan_only) + + +class SetCopTempTests(MockedBleTransportTestCase): + """Live-attempted; car rejected with ``not_supported`` (state unchanged, + not a library bug - see module docstring). Mocked-transport still proves + proto construction and rejection decoding.""" + + async def test_sends_medium_activation_temp(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + await vehicle.set_cop_temp(1) + + vehicle_action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + cop = vehicle_action.setCopTempAction + self.assertEqual( + cop.copActivationTemp, + ClimateState.CopActivationTemp.CopActivationTempMedium, + ) + + async def test_decodes_not_supported_rejection(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = _rejection_reply("not_supported") + + result = await vehicle.set_cop_temp(1) + + self.assertEqual( + result, {"response": {"result": False, "reason": "not_supported"}} + ) + + +class RemoteSeatHeaterRequestTests(MockedBleTransportTestCase): + """Live-attempted; car rejected with ``cabin comfort remote settings not + enabled`` (``remote_heater_control_enabled=False`` on the test car - see + AGENTS.md). ``test_medium_level_regression`` locks the ``SEAT_HEATER_MED`` + fix (was incorrectly ``SEAT_HEATER_MEDIUM``, found live during PR-4).""" + + async def test_sends_front_left_low(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + await vehicle.remote_seat_heater_request(0, 1) + + vehicle_action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + heater = vehicle_action.hvacSeatHeaterActions.hvacSeatHeaterAction[0] + self.assertEqual(heater.WhichOneof("seat_position"), "CAR_SEAT_FRONT_LEFT") + self.assertEqual(heater.WhichOneof("seat_heater_level"), "SEAT_HEATER_LOW") + + async def test_medium_level_regression(self) -> None: + """Level 2 must map to the proto's SEAT_HEATER_MED, not SEAT_HEATER_MEDIUM.""" + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + await vehicle.remote_seat_heater_request(0, 2) + + vehicle_action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + heater = vehicle_action.hvacSeatHeaterActions.hvacSeatHeaterAction[0] + self.assertEqual(heater.WhichOneof("seat_heater_level"), "SEAT_HEATER_MED") + + async def test_decodes_remote_settings_not_enabled_rejection(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = _rejection_reply( + "cabin comfort remote settings not enabled" + ) + + result = await vehicle.remote_seat_heater_request(0, 1) + + self.assertEqual(result["response"]["result"], False) + self.assertEqual( + result["response"]["reason"], + "cabin comfort remote settings not enabled", + ) + + +class RemoteSeatCoolerRequestTests(MockedBleTransportTestCase): + """Deferred live (no seat coolers on this Model 3 - ``seat_fan_front_*`` + absent from every live ``climate_state()`` read). Mocked-transport only.""" + + async def test_sends_front_left_low(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + await vehicle.remote_seat_cooler_request(0, 1) + + vehicle_action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + cooler = vehicle_action.hvacSeatCoolerActions.hvacSeatCoolerAction[0] + self.assertEqual( + cooler.seat_position, + HvacSeatCoolerActions.HvacSeatCoolerPosition_FrontLeft, + ) + self.assertEqual( + cooler.seat_cooler_level, + HvacSeatCoolerActions.HvacSeatCoolerLevel_Low, + ) + + +class RemoteAutoSeatClimateRequestTests(MockedBleTransportTestCase): + """Live-attempted; car rejected with ``cabin comfort remote settings not + enabled`` (same gate as the seat heater group).""" + + async def test_front_left_uses_1_indexed_autoseat(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + await vehicle.remote_auto_seat_climate_request(AutoSeat.FRONT_LEFT, True) + + vehicle_action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + carseat = vehicle_action.autoSeatClimateAction.carseat[0] + self.assertTrue(carseat.on) + self.assertEqual(carseat.seat_position, AutoSeat.FRONT_LEFT) + self.assertEqual(AutoSeat.FRONT_LEFT, 1) + + +class RemoteAutoSteeringWheelHeatClimateRequestTests(MockedBleTransportTestCase): + """Live-attempted; car rejected with ``cabin comfort remote settings not + enabled`` (same gate as the seat heater group).""" + + async def test_sends_auto_stw_heat_off(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + await vehicle.remote_auto_steering_wheel_heat_climate_request(False) + + vehicle_action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + self.assertFalse(vehicle_action.autoStwHeatAction.on) + + +class RemoteSteeringWheelHeaterRequestTests(MockedBleTransportTestCase): + """Live-attempted; car rejected with ``cabin comfort remote settings not + enabled`` (same gate as the seat heater group).""" + + async def test_sends_power_on(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + await vehicle.remote_steering_wheel_heater_request(True) + + vehicle_action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + self.assertTrue(vehicle_action.hvacSteeringWheelHeaterAction.power_on) + + +class RemoteSteeringWheelHeatLevelRequestTests(MockedBleTransportTestCase): + """Live-attempted; car rejected with ``cabin comfort remote settings not + enabled`` (same gate as the seat heater group).""" + + async def test_low_level_sends_stw_heat_low(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + await vehicle.remote_steering_wheel_heat_level_request(1) + + vehicle_action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + stw = vehicle_action.stwHeatLevelAction + self.assertEqual(stw.stw_heat_level, StwHeatLevel.StwHeatLevel_Low) + + +class SetBioweaponModeTests(MockedBleTransportTestCase): + """Deferred live (no HEPA filter on this Model 3 - ``bioweapon_mode_on`` + absent from every live ``climate_state()`` read). Mocked-transport only.""" + + async def test_sends_on_and_manual_override(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + await vehicle.set_bioweapon_mode(True, True) + + vehicle_action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + bioweapon = vehicle_action.hvacBioweaponModeAction + self.assertTrue(bioweapon.on) + self.assertTrue(bioweapon.manual_override) + + +class SetPreconditioningMaxTests(MockedBleTransportTestCase): + """Deferred live (no ``ClimateState`` field reliably reflects the + preconditioning-max override flag - ``is_preconditioning`` tracks actual + HVAC activity, not the override, so it cannot be safely + snapshot/verify/restored over BLE). Mocked-transport only.""" + + async def test_sends_on_and_manual_override(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + await vehicle.set_preconditioning_max(True, True) + + vehicle_action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + precon = vehicle_action.hvacSetPreconditioningMaxAction + self.assertTrue(precon.on) + self.assertTrue(precon.manual_override) + + +class SetRecirculationTests(MockedBleTransportTestCase): + """Deferred live (``ClimateState`` has no recirculation field at all - + confirmed against ``proto/vehicle.proto``, so the command's effect is not + observable over BLE). Mocked-transport only.""" + + async def test_sends_recirculation_on(self) -> None: + vehicle, send = self.make_vehicle() + send.return_value = infotainment_action_ok_reply() + + await vehicle.set_recirculation(True) + + vehicle_action = _decode_vehicle_action(vehicle, send.await_args.args[0]) + self.assertTrue(vehicle_action.hvacRecirculationAction.on)