diff --git a/README.md b/README.md index 9f3039c..35467e8 100644 --- a/README.md +++ b/README.md @@ -50,3 +50,44 @@ editing one, regenerate its `*_pb2` modules (don't edit those by hand): uv run python -m grpc_tools.protoc --proto_path=src --python_out=src \ --pyi_out=src src/rivian/parallax/proto/*.proto ``` + +### Commands + +`send_parallax_command` sends a signed command, such as unlocking +(`parallax.UNLOCK_RVM` with `parallax.encode_unlock()`; `LOCK_RVM` and +`encode_lock()` to lock), and `subscribe_for_parallax_command_state` follows it. +Subscribe first, then send: + +```python +import uuid + +from rivian import parallax + + +def on_state(data): + if state := parallax.decode_command_state_message(data): + print(state["progress"], state["status"], state["error"]) + + +command_id = str(uuid.uuid4()) +unsubscribe = await rivian.subscribe_for_parallax_command_state( + vehicle_id, command_id, on_state +) +if unsubscribe: + await rivian.send_parallax_command( + vehicle_id, + parallax.UNLOCK_RVM, + parallax.encode_unlock(), + identity_id=identity_id, + vehicle_key=vehicle_key, + private_key=private_key, + command_id=command_id, + ) + ... # wait for the progress "complete", or give up + await unsubscribe() +``` + +The keys are those of `send_vehicle_command`. The command is done when the progress +is `complete`, the status `success` and there is no `error`. If you never see that, +check the vehicle before sending it again. This unlocks a real vehicle, so keep the +keys secret. diff --git a/src/rivian/parallax/__init__.py b/src/rivian/parallax/__init__.py index ab6bb7d..2d97c6d 100644 --- a/src/rivian/parallax/__init__.py +++ b/src/rivian/parallax/__init__.py @@ -10,9 +10,15 @@ encode_vehicle_operation(rvm, payload=b"", ...) -> str Builds a `send_vehicle_operation` request; an empty payload reads the RVM, anything else (e.g. `encode_climate_hold_setting`) writes it. + encode_command(vehicle_id, rvm, payload, ...) -> str + Builds and signs a `send_parallax_command` request, with a payload + such as `encode_unlock()`. + decode_command_state_message(data) -> dict | None + Reads a raw `subscribe_for_parallax_command_state` message. Layout: - core.py - registry, payload parsing and shared helpers + core.py - registry, payload parsing and shared helpers + command.py - signed remote commands and their state messages proto/ - .proto schemas and their generated *_pb2 modules .py - one module per RVM topic prefix (body.*, etc.) @@ -44,11 +50,13 @@ vehicle, vehicle_access, ) +from .body import LOCK_RVM, UNLOCK_RVM, encode_lock, encode_unlock from .comfort import ( CLIMATE_HOLD_SETTING_RVM, CLIMATE_HOLD_STATUS_RVM, encode_climate_hold_setting, ) +from .command import decode_command_state_message, encode_command from .core import ( RVMDecoder, decode_parallax_message, @@ -75,9 +83,15 @@ "CHARGING_RVMS", "CLIMATE_HOLD_SETTING_RVM", "CLIMATE_HOLD_STATUS_RVM", + "LOCK_RVM", "PARALLAX_RVMS", + "UNLOCK_RVM", + "decode_command_state_message", "decode_parallax_message", "decode_parallax_subscription_message", "encode_climate_hold_setting", + "encode_command", + "encode_lock", + "encode_unlock", "encode_vehicle_operation", ] diff --git a/src/rivian/parallax/body.py b/src/rivian/parallax/body.py index ab71231..6107bd1 100644 --- a/src/rivian/parallax/body.py +++ b/src/rivian/parallax/body.py @@ -7,6 +7,9 @@ from .core import _LOGGER, RVMDecoder, _enum, _present from .proto import body_pb2 +LOCK_RVM: Final = "body.locks.lock" +UNLOCK_RVM: Final = "body.locks.unlock" + _CLOSURE_MAP: Final[dict[int, str]] = { body_pb2.DOOR_FRONT_LEFT: "doorFrontLeftClosed", body_pb2.DOOR_FRONT_RIGHT: "doorFrontRightClosed", @@ -173,6 +176,16 @@ def decode_closures(m: body_pb2.ClosuresState) -> dict[str, Any]: return result +def encode_lock() -> bytes: + """Encode a body.locks.lock payload, which has no fields.""" + return body_pb2.LockCommand().SerializeToString() + + +def encode_unlock() -> bytes: + """Encode a body.locks.unlock payload that unlocks everything.""" + return body_pb2.UnlockCommand(all=True).SerializeToString() + + @RVMDecoder.register("body.locks.states", body_pb2.LocksState) def decode_locks(m: body_pb2.LocksState) -> dict[str, Any]: """body.locks.states — lock state of every lock. diff --git a/src/rivian/parallax/command.py b/src/rivian/parallax/command.py new file mode 100644 index 0000000..be6bd81 --- /dev/null +++ b/src/rivian/parallax/command.py @@ -0,0 +1,168 @@ +"""Encoder and decoder for signed Parallax remote commands. + +A command such as `body.locks.unlock` is sent with the `sendParallaxPayload` +mutation (`Rivian.send_parallax_command`). Its payload is an `Operation` in an +`Envelope`, signed with the secret that `Rivian.send_vehicle_command` derives from +the phone's key and the vehicle's, but over the operation's bytes. In testing, +Rivian woke a sleeping vehicle for it. + +The `vehicleCommandStatePx` subscription +(`Rivian.subscribe_for_parallax_command_state`) reports on the command as it +goes: the vehicle waking up, validating and executing it, then the result. +""" + +from __future__ import annotations + +import base64 +import time +import uuid +from typing import Any, Final + +from google.protobuf.message import DecodeError + +from ..utils import generate_ble_command_hmac +from .core import _LOGGER, _debug_message, _enum, _present +from .proto import command_pb2 + +_PROGRESS_MAP: Final[dict[int, str]] = { + command_pb2.VEHICLE_MODEL_OP_PROGRESS_UNSPECIFIED: "unspecified", + command_pb2.VEHICLE_MODEL_OP_PROGRESS_VEHICLE_WAKING_UP: "waking_up", + command_pb2.VEHICLE_MODEL_OP_PROGRESS_VEHICLE_VALIDATING: "validating", + command_pb2.VEHICLE_MODEL_OP_PROGRESS_VEHICLE_EXECUTING: "executing", + command_pb2.VEHICLE_MODEL_OP_PROGRESS_VEHICLE_COMPLETE: "complete", +} + +_STATUS_MAP: Final[dict[int, str]] = { + command_pb2.RESPONSE_STATUS_UNDEFINED: "undefined", + command_pb2.RESPONSE_STATUS_SUCCESS: "success", + command_pb2.RESPONSE_STATUS_FAIL: "failed", + command_pb2.RESPONSE_STATUS_INVALID_REQUEST: "invalid_request", + command_pb2.RESPONSE_STATUS_NOT_SUPPORTED: "not_supported", + command_pb2.RESPONSE_STATUS_TIMEOUT: "timeout", +} + + +def encode_command( + vehicle_id: str, + rvm: str, + payload: bytes, + *, + identity_id: str, + vehicle_key: str, + private_key: str, + command_id: str | None = None, + timestamp: float | None = None, +) -> str: + """Encode and sign a command, as the payload of `sendParallaxPayload`. + + `payload` is the RVM's message, e.g. `encode_unlock()` for `UNLOCK_RVM`; + nothing checks that they go together. `identity_id`, `vehicle_key` and + `private_key` are the ones `send_vehicle_command` takes. The command id, a + UUID, is a new one unless `command_id` is given (`ValueError` if it isn't + one); `timestamp` is epoch milliseconds, now unless given. + + The result is signed and can be replayed until the vehicle rejects its + timestamp, so treat it as a secret. + """ + command_id_bytes = ( + uuid.uuid4().bytes if command_id is None else uuid.UUID(command_id).bytes + ) + timestamp_ms = int(time.time() * 1000 if timestamp is None else timestamp) + stamp = command_pb2.Timestamp( + seconds=timestamp_ms // 1000, nanos=timestamp_ms % 1000 * 1_000_000 + ) + operation = command_pb2.Operation( + rvm=rvm, + type=command_pb2.PARALLAX_OPERATION_REQUEST, + command_id=command_id_bytes, + payload=payload, + created_at=stamp, + vehicle_id=vehicle_id, + ).SerializeToString() + envelope = command_pb2.Envelope( + composite=command_pb2.Composite(bytes_val=operation), + last_update=stamp, + correlation_id=command_id_bytes, + security_info=command_pb2.SecurityInfo( + signature=command_pb2.Signature( + type=command_pb2.DATA_SIGNATURE_TYPE_HMAC_SHA256, + # The raw HMAC-SHA256 of the data, as BLE commands use too + value=generate_ble_command_hmac(operation, vehicle_key, private_key), + ), + mapped_identity=identity_id, + ), + ) + return base64.b64encode(envelope.SerializeToString()).decode("utf-8") + + +def decode_command_state(payload: str) -> dict[str, Any] | None: + """Decode the payload of a `vehicleCommandStatePx` message. + + Returns None if the payload is empty or can't be read. + + Fields: + commandId: str | None — the command's UUID + rvm: str + progress: str | int — "unspecified" | "waking_up" | "validating" | + "executing" | "complete". Any of the ones before "complete" can be + skipped, and "waking_up" has come more than once for one command. + status: str | int | None — how the vehicle answered ("undefined" | + "success" | "failed" | "invalid_request" | "not_supported" | + "timeout"); None if the message has no result + error: dict | None — {"code": int, "message": str}, from the result or, + if the whole operation failed, from the operation error (which has + no status) + + A value this library doesn't know comes back as the number it was sent as. + + The vehicle has done the command when the progress is "complete", the status + "success" and there is no error. "failed", "invalid_request" and + "not_supported" mean it has not. With "timeout" or "undefined", or if no + "complete" message comes at all, it isn't known, and the vehicle may do it + later. + """ + try: + data = base64.b64decode(payload) + message = command_pb2.CommandState.FromString(data) + except (TypeError, ValueError, DecodeError): + _LOGGER.debug("Failed to decode command state", exc_info=True) + return None + if not data: + return None + _debug_message("command state", message) + + status: str | int | None = None + error: dict[str, Any] | None = None + if (result := _present(message, "data")) is not None: + status = _enum(_STATUS_MAP, result.response_status, what="command status") + if (result_error := _present(result, "error")) is not None: + error = {"code": result_error.code, "message": result_error.message} + elif (operation_error := _present(message, "operation_error")) is not None: + error = {"code": operation_error.error, "message": operation_error.message} + return { + "commandId": ( + str(uuid.UUID(bytes=message.command_id)) + if len(message.command_id) == 16 + else None + ), + "rvm": message.rvm, + "progress": _enum(_PROGRESS_MAP, message.progress, what="command progress"), + "status": status, + "error": error, + } + + +def decode_command_state_message(data: dict[str, Any]) -> dict[str, Any] | None: + """Decode a raw `subscribe_for_parallax_command_state` message. + + Unwraps `payload.data.vehicleCommandStatePx.payload` for + `decode_command_state`; returns None if it's missing or can't be read. That + includes a message that carries GraphQL errors (`data["payload"]["errors"]`) + instead of a state. + """ + payload: Any = data + for key in ("payload", "data", "vehicleCommandStatePx", "payload"): + if not isinstance(payload, dict): + return None + payload = payload.get(key) + return decode_command_state(payload) diff --git a/src/rivian/parallax/core.py b/src/rivian/parallax/core.py index 4d299d0..6376fec 100644 --- a/src/rivian/parallax/core.py +++ b/src/rivian/parallax/core.py @@ -76,6 +76,16 @@ def _describe_unknowns(message: Message, path: str = "") -> list[str]: return found +def _debug_message(label: str, message: Message) -> None: + """Debug-log a parsed `message` and anything its schema doesn't define.""" + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "%s: %s", label, text_format.MessageToString(message, as_one_line=True) + ) + for unknown in _describe_unknowns(message): + _LOGGER.debug("%s: %s", label, unknown) + + class RVMDecoder: """Registry of RVM topic -> (message class, decoder). @@ -124,12 +134,7 @@ def _decode_rvm_payload(payload: str | None, rvm: str) -> dict[str, Any]: except Exception: _LOGGER.debug("Failed to decode %s payload", rvm, exc_info=True) return {} - if _LOGGER.isEnabledFor(logging.DEBUG): - _LOGGER.debug( - "%s: %s", rvm, text_format.MessageToString(message, as_one_line=True) - ) - for unknown in _describe_unknowns(message): - _LOGGER.debug("%s: %s", rvm, unknown) + _debug_message(rvm, message) return result diff --git a/src/rivian/parallax/proto/body.proto b/src/rivian/parallax/proto/body.proto index 014e293..f89a768 100644 --- a/src/rivian/parallax/proto/body.proto +++ b/src/rivian/parallax/proto/body.proto @@ -266,3 +266,14 @@ enum WiperFluidState { WIPER_FLUID_STATE_LOW = 2; WIPER_FLUID_STATE_EMPTY = 3; } + +// body.locks.unlock, the payload of a signed command (see ../command.py) +message UnlockCommand { + oneof target { + bool all = 1; // the app always sets this + bool user_preference = 2; // in the app's schema; its button never sends it + } +} + +// body.locks.lock, the payload of a signed command. It has no fields. +message LockCommand {} diff --git a/src/rivian/parallax/proto/body_pb2.py b/src/rivian/parallax/proto/body_pb2.py index 69684d4..7e20105 100644 --- a/src/rivian/parallax/proto/body_pb2.py +++ b/src/rivian/parallax/proto/body_pb2.py @@ -24,47 +24,47 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n rivian/parallax/proto/body.proto\x12\x14rivian.parallax.body\"\x89\x06\n\rClosuresState\x12<\n\x07\x63losure\x18\x01 \x03(\x0b\x32+.rivian.parallax.body.ClosuresState.Closure\x1a\xb9\x05\n\x07\x43losure\x12+\n\x02id\x18\x01 \x01(\x0e\x32\x1f.rivian.parallax.body.ClosureId\x12\x36\n\x05state\x18\x02 \x01(\x0e\x32\".rivian.parallax.body.ClosureStateH\x01\x88\x01\x01\x12\x1d\n\x15open_position_percent\x18\x03 \x01(\x05\x12\x31\n\x05\x66\x61ult\x18\x04 \x01(\x0e\x32\".rivian.parallax.body.ClosureFault\x12\x46\n\x13windows_next_action\x18\x05 \x01(\x0e\x32\'.rivian.parallax.body.WindowsNextActionH\x00\x12\x42\n\x11\x66runk_next_action\x18\x06 \x01(\x0e\x32%.rivian.parallax.body.FrunkNextActionH\x00\x12H\n\x14liftgate_next_action\x18\x07 \x01(\x0e\x32(.rivian.parallax.body.LiftgateNextActionH\x00\x12G\n\x14side_bin_next_action\x18\x08 \x01(\x0e\x32\'.rivian.parallax.body.SideBinNextActionH\x00\x12H\n\x14tailgate_next_action\x18\t \x01(\x0e\x32(.rivian.parallax.body.TailgateNextActionH\x00\x12V\n\x1c\x63harge_port_door_next_action\x18\n \x01(\x0e\x32..rivian.parallax.body.ChargePortDoorNextActionH\x00\x12\x1d\n\x13tonneau_next_action\x18\x0b \x01(\x05H\x00\x42\r\n\x0bnext_actionB\x08\n\x06_state\"\xe3\x01\n\nLocksState\x12\x33\n\x04lock\x18\x01 \x03(\x0b\x32%.rivian.parallax.body.LocksState.Lock\x1a\x9f\x01\n\x04Lock\x12(\n\x02id\x18\x01 \x01(\x0e\x32\x1c.rivian.parallax.body.LockId\x12\x33\n\x05state\x18\x02 \x01(\x0e\x32\x1f.rivian.parallax.body.LockStateH\x00\x88\x01\x01\x12.\n\x05\x66\x61ult\x18\x03 \x01(\x0e\x32\x1f.rivian.parallax.body.LockFaultB\x08\n\x06_state\"\xd1\x01\n\x0cWindowsState\x12\x39\n\x06window\x18\x01 \x03(\x0b\x32).rivian.parallax.body.WindowsState.Window\x1a\x85\x01\n\x06Window\x12\x36\n\x08instance\x18\x01 \x01(\x0e\x32$.rivian.parallax.body.WindowInstance\x12\x43\n\x12\x63\x61libration_status\x18\x02 \x01(\x0e\x32\'.rivian.parallax.body.CalibrationStatus\"\x9b\x01\n\x0cTrailerState\x12<\n\x08presence\x18\x01 \x01(\x0e\x32%.rivian.parallax.body.TrailerPresenceH\x00\x88\x01\x01\x12@\n\x11rear_hitch_status\x18\x02 \x01(\x0e\x32%.rivian.parallax.body.RearHitchStatusB\x0b\n\t_presence\"G\n\x0fWiperFluidLevel\x12\x34\n\x05state\x18\x02 \x01(\x0e\x32%.rivian.parallax.body.WiperFluidState*\xe8\x02\n\tClosureId\x12\x1a\n\x16\x43LOSURE_ID_UNSPECIFIED\x10\x00\x12\x13\n\x0f\x44OOR_FRONT_LEFT\x10\x01\x12\x14\n\x10\x44OOR_FRONT_RIGHT\x10\x02\x12\x12\n\x0e\x44OOR_REAR_LEFT\x10\x03\x12\x13\n\x0f\x44OOR_REAR_RIGHT\x10\x04\x12\t\n\x05\x46RUNK\x10\x05\x12\x0c\n\x08TAILGATE\x10\x06\x12\x0c\n\x08LIFTGATE\x10\x07\x12\x11\n\rSIDE_BIN_LEFT\x10\x08\x12\x12\n\x0eSIDE_BIN_RIGHT\x10\t\x12\x0f\n\x0b\x43HARGE_PORT\x10\n\x12\x0b\n\x07TONNEAU\x10\x0b\x12\x15\n\x11WINDOW_FRONT_LEFT\x10\x0c\x12\x16\n\x12WINDOW_FRONT_RIGHT\x10\r\x12\x14\n\x10WINDOW_REAR_LEFT\x10\x0e\x12\x15\n\x11WINDOW_REAR_RIGHT\x10\x0f\x12\x0f\n\x0bWINDOW_REAR\x10\x10\x12\x12\n\rGROUP_WINDOWS\x10\x90N*\xad\x01\n\x0c\x43losureState\x12\x1d\n\x19\x43LOSURE_STATE_UNSPECIFIED\x10\x00\x12\x16\n\x12\x43LOSURE_STATE_OPEN\x10\x01\x12\x18\n\x14\x43LOSURE_STATE_CLOSED\x10\x02\x12\x16\n\x12\x43LOSURE_STATE_AJAR\x10\x03\x12\x19\n\x15\x43LOSURE_STATE_OPENING\x10\x04\x12\x19\n\x15\x43LOSURE_STATE_CLOSING\x10\x05*\xf3\x02\n\x06LockId\x12\x17\n\x13LOCK_ID_UNSPECIFIED\x10\x00\x12\x18\n\x14LOCK_DOOR_FRONT_LEFT\x10\x01\x12\x19\n\x15LOCK_DOOR_FRONT_RIGHT\x10\x02\x12\x17\n\x13LOCK_DOOR_REAR_LEFT\x10\x03\x12\x18\n\x14LOCK_DOOR_REAR_RIGHT\x10\x04\x12\x0e\n\nLOCK_FRUNK\x10\x05\x12\x11\n\rLOCK_TAILGATE\x10\x06\x12\x11\n\rLOCK_LIFTGATE\x10\x07\x12\x16\n\x12LOCK_SIDE_BIN_LEFT\x10\x08\x12\x17\n\x13LOCK_SIDE_BIN_RIGHT\x10\t\x12\x14\n\x10LOCK_CHARGE_PORT\x10\n\x12\x17\n\x13LOCK_TRUNK_SECURITY\x10\x0b\x12\x17\n\x13LOCK_CENTER_CONSOLE\x10\x0c\x12\x12\n\x0eLOCK_GLOVE_BOX\x10\r\x12\x13\n\x0fLOCK_GEAR_GUARD\x10\x0e\x12\x10\n\x0cLOCK_TONNEAU\x10\x0f*z\n\tLockState\x12\x1a\n\x16LOCK_STATE_UNSPECIFIED\x10\x00\x12\x15\n\x11LOCK_STATE_LOCKED\x10\x01\x12\x17\n\x13LOCK_STATE_UNLOCKED\x10\x02\x12!\n\x1dLOCK_STATE_PARTIALLY_UNLOCKED\x10\x03*?\n\tLockFault\x12\x1a\n\x16LOCK_FAULT_UNSPECIFIED\x10\x00\x12\x16\n\x12LOCK_FAULT_FAULTED\x10\x01*\xcb\x01\n\x0eWindowInstance\x12\x1f\n\x1bWINDOW_INSTANCE_UNSPECIFIED\x10\x00\x12\x1e\n\x1aWINDOW_INSTANCE_FRONT_LEFT\x10\x01\x12\x1f\n\x1bWINDOW_INSTANCE_FRONT_RIGHT\x10\x02\x12\x1d\n\x19WINDOW_INSTANCE_REAR_LEFT\x10\x03\x12\x1e\n\x1aWINDOW_INSTANCE_REAR_RIGHT\x10\x04\x12\x18\n\x14WINDOW_INSTANCE_REAR\x10\x05*\x81\x01\n\x11\x43\x61librationStatus\x12\"\n\x1e\x43\x41LIBRATION_STATUS_UNSPECIFIED\x10\x00\x12!\n\x1d\x43\x41LIBRATION_STATUS_CALIBRATED\x10\x01\x12%\n!CALIBRATION_STATUS_NOT_CALIBRATED\x10\x02*\xd8\x01\n\x0fRearHitchStatus\x12!\n\x1dREAR_HITCH_STATUS_UNSPECIFIED\x10\x00\x12!\n\x1dREAR_HITCH_STATUS_NOT_PRESENT\x10\x01\x12\x1f\n\x1bREAR_HITCH_STATUS_ACCESSORY\x10\x02\x12\x1e\n\x1aREAR_HITCH_STATUS_TRAILER1\x10\x03\x12\x1e\n\x1aREAR_HITCH_STATUS_TRAILER2\x10\x04\x12\x1e\n\x1aREAR_HITCH_STATUS_TRAILER3\x10\x05*\xaa\x01\n\x0c\x43losureFault\x12\x1d\n\x19\x43LOSURE_FAULT_UNSPECIFIED\x10\x00\x12\x1a\n\x16\x43LOSURE_FAULT_NO_FAULT\x10\x01\x12\x19\n\x15\x43LOSURE_FAULT_GENERAL\x10\x02\x12&\n\"CLOSURE_FAULT_NO_POWERED_OPERATION\x10\x03\x12\x1c\n\x18\x43LOSURE_FAULT_OBSTRUCTED\x10\x04*\x97\x01\n\x0fTrailerPresence\x12 \n\x1cTRAILER_PRESENCE_UNSPECIFIED\x10\x00\x12\x17\n\x13TRAILER_NOT_PRESENT\x10\x01\x12\x13\n\x0fTRAILER_PRESENT\x10\x02\x12\x1f\n\x1bTRAILER_PRESENT_WITH_BRAKES\x10\x03\x12\x13\n\x0fTRAILER_INVALID\x10\x04*\x9f\x04\n\x18\x43hargePortDoorNextAction\x12\x18\n\x14\x43HARGE_PORT_DOOR_SNA\x10\x00\x12!\n\x1d\x43HARGE_PORT_DOOR_OPEN_ALLOWED\x10\x01\x12\'\n#CHARGE_PORT_DOOR_OPEN_NOT_AVAILABLE\x10\x02\x12-\n)CHARGE_PORT_DOOR_OPEN_NOT_ALLOWED_FAULTED\x10\x03\x12\x1c\n\x18\x43HARGE_PORT_DOOR_OPENING\x10\x04\x12\"\n\x1e\x43HARGE_PORT_DOOR_CLOSE_ALLOWED\x10\x05\x12;\n7CHARGE_PORT_DOOR_OBSTRUCTED_WHILE_OPENING_CLOSE_ALLOWED\x10\x06\x12:\n6CHARGE_PORT_DOOR_OBSTRUCTED_WHILE_OPENING_OPEN_ALLOWED\x10\x07\x12;\n7CHARGE_PORT_DOOR_OBSTRUCTED_WHILE_CLOSING_CLOSE_ALLOWED\x10\x08\x12(\n$CHARGE_PORT_DOOR_CLOSE_NOT_AVAILABLE\x10\t\x12.\n*CHARGE_PORT_DOOR_CLOSE_NOT_ALLOWED_FAULTED\x10\n\x12\x1c\n\x18\x43HARGE_PORT_DOOR_CLOSING\x10\x0b*\xb0\x03\n\x0f\x46runkNextAction\x12\r\n\tFRUNK_SNA\x10\x00\x12\x16\n\x12\x46RUNK_OPEN_ALLOWED\x10\x01\x12\x17\n\x13\x46RUNK_CLOSE_ALLOWED\x10\x02\x12\x11\n\rFRUNK_OPENING\x10\x03\x12\x11\n\rFRUNK_CLOSING\x10\x04\x12\x1c\n\x18\x46RUNK_OPEN_NOT_AVAILABLE\x10\x05\x12\x1d\n\x19\x46RUNK_CLOSE_NOT_AVAILABLE\x10\x06\x12\"\n\x1e\x46RUNK_OPEN_NOT_ALLOWED_FAULTED\x10\x07\x12#\n\x1f\x46RUNK_CLOSE_NOT_ALLOWED_FAULTED\x10\x08\x12)\n%FRUNK_OPEN_ALLOWED_NO_POWER_OPERATION\x10\t\x12.\n*FRUNK_CLOSE_NOT_ALLOWED_NO_POWER_OPERATION\x10\n\x12*\n&FRUNK_OBSTRUCTED_OPENING_CLOSE_ALLOWED\x10\x0b\x12*\n&FRUNK_OBSTRUCTED_CLOSING_CLOSE_ALLOWED\x10\x0c*\xa7\x06\n\x12LiftgateNextAction\x12\x10\n\x0cLIFTGATE_SNA\x10\x00\x12\x19\n\x15LIFTGATE_OPEN_ALLOWED\x10\x01\x12\x1a\n\x16LIFTGATE_CLOSE_ALLOWED\x10\x02\x12\x14\n\x10LIFTGATE_OPENING\x10\x03\x12\x14\n\x10LIFTGATE_CLOSING\x10\x04\x12\x1f\n\x1bLIFTGATE_OPEN_NOT_AVAILABLE\x10\x05\x12 \n\x1cLIFTGATE_CLOSE_NOT_AVAILABLE\x10\x06\x12%\n!LIFTGATE_OPEN_NOT_ALLOWED_FAULTED\x10\x07\x12&\n\"LIFTGATE_CLOSE_NOT_ALLOWED_FAULTED\x10\x08\x12,\n(LIFTGATE_OPEN_ALLOWED_NO_POWER_OPERATION\x10\t\x12\x31\n-LIFTGATE_CLOSE_NOT_ALLOWED_NO_POWER_OPERATION\x10\n\x12-\n)LIFTGATE_OBSTRUCTED_OPENING_CLOSE_ALLOWED\x10\x0b\x12-\n)LIFTGATE_OBSTRUCTED_CLOSING_CLOSE_ALLOWED\x10\x0c\x12.\n*LIFTGATE_LOWER_GATE_OPEN_CLOSE_NOT_ALLOWED\x10\r\x12&\n\"LIFTGATE_OPENING_PAUSE_NOT_ALLOWED\x10\x0e\x12&\n\"LIFTGATE_CLOSING_PAUSE_NOT_ALLOWED\x10\x0f\x12+\n\'LIFTGATE_OPEN_ALLOWED_OBSTACLE_DETECTED\x10\x10\x12*\n&LIFTGATE_OPEN_ALLOWED_TRAILER_DETECTED\x10\x11\x12,\n(LIFTGATE_CLOSE_ALLOWED_OBSTACLE_DETECTED\x10\x12\x12+\n\'LIFTGATE_CLOSE_ALLOWED_TRAILER_DETECTED\x10\x13\x12\x17\n\x13LIFTGATE_PROCESSING\x10\x14*\xb2\x02\n\x11SideBinNextAction\x12\x10\n\x0cSIDE_BIN_SNA\x10\x00\x12\x19\n\x15SIDE_BIN_OPEN_ALLOWED\x10\x01\x12\x14\n\x10SIDE_BIN_OPENING\x10\x02\x12\x1f\n\x1bSIDE_BIN_OPEN_NOT_AVAILABLE\x10\x03\x12%\n!SIDE_BIN_OPEN_NOT_ALLOWED_FAULTED\x10\x04\x12\x32\n.SIDE_BIN_STUCK_AJAR_WHILE_OPENING_OPEN_ALLOWED\x10\x05\x12-\n)SIDE_BIN_OPEN_ALREADY_NO_ACTION_AVAILABLE\x10\x06\x12/\n+SIDE_BIN_OPEN_ALLOWED_CONFIRM_VEHICLE_ANGLE\x10\x07*\x8c\x03\n\x12TailgateNextAction\x12\x10\n\x0cTAILGATE_SNA\x10\x00\x12\x19\n\x15TAILGATE_OPEN_ALLOWED\x10\x01\x12\x14\n\x10TAILGATE_OPENING\x10\x02\x12\x1f\n\x1bTAILGATE_OPEN_NOT_AVAILABLE\x10\x03\x12%\n!TAILGATE_OPEN_NOT_ALLOWED_FAULTED\x10\x04\x12\x32\n.TAILGATE_STUCK_AJAR_WHILE_OPENING_OPEN_ALLOWED\x10\x05\x12-\n)TAILGATE_OPEN_ALREADY_NO_ACTION_AVAILABLE\x10\x06\x12/\n+TAILGATE_OPEN_ALLOWED_CONFIRM_VEHICLE_ANGLE\x10\x07\x12+\n\'TAILGATE_OPEN_ALLOWED_OBSTACLE_DETECTED\x10\x08\x12*\n&TAILGATE_OPEN_ALLOWED_TRAILER_DETECTED\x10\t*\xb0\x03\n\x11WindowsNextAction\x12\x0f\n\x0bWINDOWS_SNA\x10\x00\x12\x18\n\x14WINDOWS_OPEN_ALLOWED\x10\x01\x12\x19\n\x15WINDOWS_CLOSE_ALLOWED\x10\x02\x12\x13\n\x0fWINDOWS_OPENING\x10\x03\x12\x13\n\x0fWINDOWS_CLOSING\x10\x04\x12\x12\n\x0eWINDOWS_MOVING\x10\x05\x12\x1e\n\x1aWINDOWS_OPEN_NOT_AVAILABLE\x10\x06\x12\x1f\n\x1bWINDOWS_CLOSE_NOT_AVAILABLE\x10\x07\x12$\n WINDOWS_OPEN_NOT_ALLOWED_FAULTED\x10\x08\x12%\n!WINDOWS_CLOSE_NOT_ALLOWED_FAULTED\x10\t\x12\x32\n.WINDOWS_OBSTRUCTED_WHILE_CLOSING_CLOSE_ALLOWED\x10\n\x12*\n&WINDOWS_CLOSE_NOT_ALLOWED_UNCALIBRATED\x10\x0b\x12)\n%WINDOWS_OPEN_NOT_ALLOWED_UNCALIBRATED\x10\x0c*\x8a\x01\n\x0fWiperFluidState\x12!\n\x1dWIPER_FLUID_STATE_UNSPECIFIED\x10\x00\x12\x1c\n\x18WIPER_FLUID_STATE_NORMAL\x10\x01\x12\x19\n\x15WIPER_FLUID_STATE_LOW\x10\x02\x12\x1b\n\x17WIPER_FLUID_STATE_EMPTY\x10\x03\x62\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n rivian/parallax/proto/body.proto\x12\x14rivian.parallax.body\"\x89\x06\n\rClosuresState\x12<\n\x07\x63losure\x18\x01 \x03(\x0b\x32+.rivian.parallax.body.ClosuresState.Closure\x1a\xb9\x05\n\x07\x43losure\x12+\n\x02id\x18\x01 \x01(\x0e\x32\x1f.rivian.parallax.body.ClosureId\x12\x36\n\x05state\x18\x02 \x01(\x0e\x32\".rivian.parallax.body.ClosureStateH\x01\x88\x01\x01\x12\x1d\n\x15open_position_percent\x18\x03 \x01(\x05\x12\x31\n\x05\x66\x61ult\x18\x04 \x01(\x0e\x32\".rivian.parallax.body.ClosureFault\x12\x46\n\x13windows_next_action\x18\x05 \x01(\x0e\x32\'.rivian.parallax.body.WindowsNextActionH\x00\x12\x42\n\x11\x66runk_next_action\x18\x06 \x01(\x0e\x32%.rivian.parallax.body.FrunkNextActionH\x00\x12H\n\x14liftgate_next_action\x18\x07 \x01(\x0e\x32(.rivian.parallax.body.LiftgateNextActionH\x00\x12G\n\x14side_bin_next_action\x18\x08 \x01(\x0e\x32\'.rivian.parallax.body.SideBinNextActionH\x00\x12H\n\x14tailgate_next_action\x18\t \x01(\x0e\x32(.rivian.parallax.body.TailgateNextActionH\x00\x12V\n\x1c\x63harge_port_door_next_action\x18\n \x01(\x0e\x32..rivian.parallax.body.ChargePortDoorNextActionH\x00\x12\x1d\n\x13tonneau_next_action\x18\x0b \x01(\x05H\x00\x42\r\n\x0bnext_actionB\x08\n\x06_state\"\xe3\x01\n\nLocksState\x12\x33\n\x04lock\x18\x01 \x03(\x0b\x32%.rivian.parallax.body.LocksState.Lock\x1a\x9f\x01\n\x04Lock\x12(\n\x02id\x18\x01 \x01(\x0e\x32\x1c.rivian.parallax.body.LockId\x12\x33\n\x05state\x18\x02 \x01(\x0e\x32\x1f.rivian.parallax.body.LockStateH\x00\x88\x01\x01\x12.\n\x05\x66\x61ult\x18\x03 \x01(\x0e\x32\x1f.rivian.parallax.body.LockFaultB\x08\n\x06_state\"\xd1\x01\n\x0cWindowsState\x12\x39\n\x06window\x18\x01 \x03(\x0b\x32).rivian.parallax.body.WindowsState.Window\x1a\x85\x01\n\x06Window\x12\x36\n\x08instance\x18\x01 \x01(\x0e\x32$.rivian.parallax.body.WindowInstance\x12\x43\n\x12\x63\x61libration_status\x18\x02 \x01(\x0e\x32\'.rivian.parallax.body.CalibrationStatus\"\x9b\x01\n\x0cTrailerState\x12<\n\x08presence\x18\x01 \x01(\x0e\x32%.rivian.parallax.body.TrailerPresenceH\x00\x88\x01\x01\x12@\n\x11rear_hitch_status\x18\x02 \x01(\x0e\x32%.rivian.parallax.body.RearHitchStatusB\x0b\n\t_presence\"G\n\x0fWiperFluidLevel\x12\x34\n\x05state\x18\x02 \x01(\x0e\x32%.rivian.parallax.body.WiperFluidState\"C\n\rUnlockCommand\x12\r\n\x03\x61ll\x18\x01 \x01(\x08H\x00\x12\x19\n\x0fuser_preference\x18\x02 \x01(\x08H\x00\x42\x08\n\x06target\"\r\n\x0bLockCommand*\xe8\x02\n\tClosureId\x12\x1a\n\x16\x43LOSURE_ID_UNSPECIFIED\x10\x00\x12\x13\n\x0f\x44OOR_FRONT_LEFT\x10\x01\x12\x14\n\x10\x44OOR_FRONT_RIGHT\x10\x02\x12\x12\n\x0e\x44OOR_REAR_LEFT\x10\x03\x12\x13\n\x0f\x44OOR_REAR_RIGHT\x10\x04\x12\t\n\x05\x46RUNK\x10\x05\x12\x0c\n\x08TAILGATE\x10\x06\x12\x0c\n\x08LIFTGATE\x10\x07\x12\x11\n\rSIDE_BIN_LEFT\x10\x08\x12\x12\n\x0eSIDE_BIN_RIGHT\x10\t\x12\x0f\n\x0b\x43HARGE_PORT\x10\n\x12\x0b\n\x07TONNEAU\x10\x0b\x12\x15\n\x11WINDOW_FRONT_LEFT\x10\x0c\x12\x16\n\x12WINDOW_FRONT_RIGHT\x10\r\x12\x14\n\x10WINDOW_REAR_LEFT\x10\x0e\x12\x15\n\x11WINDOW_REAR_RIGHT\x10\x0f\x12\x0f\n\x0bWINDOW_REAR\x10\x10\x12\x12\n\rGROUP_WINDOWS\x10\x90N*\xad\x01\n\x0c\x43losureState\x12\x1d\n\x19\x43LOSURE_STATE_UNSPECIFIED\x10\x00\x12\x16\n\x12\x43LOSURE_STATE_OPEN\x10\x01\x12\x18\n\x14\x43LOSURE_STATE_CLOSED\x10\x02\x12\x16\n\x12\x43LOSURE_STATE_AJAR\x10\x03\x12\x19\n\x15\x43LOSURE_STATE_OPENING\x10\x04\x12\x19\n\x15\x43LOSURE_STATE_CLOSING\x10\x05*\xf3\x02\n\x06LockId\x12\x17\n\x13LOCK_ID_UNSPECIFIED\x10\x00\x12\x18\n\x14LOCK_DOOR_FRONT_LEFT\x10\x01\x12\x19\n\x15LOCK_DOOR_FRONT_RIGHT\x10\x02\x12\x17\n\x13LOCK_DOOR_REAR_LEFT\x10\x03\x12\x18\n\x14LOCK_DOOR_REAR_RIGHT\x10\x04\x12\x0e\n\nLOCK_FRUNK\x10\x05\x12\x11\n\rLOCK_TAILGATE\x10\x06\x12\x11\n\rLOCK_LIFTGATE\x10\x07\x12\x16\n\x12LOCK_SIDE_BIN_LEFT\x10\x08\x12\x17\n\x13LOCK_SIDE_BIN_RIGHT\x10\t\x12\x14\n\x10LOCK_CHARGE_PORT\x10\n\x12\x17\n\x13LOCK_TRUNK_SECURITY\x10\x0b\x12\x17\n\x13LOCK_CENTER_CONSOLE\x10\x0c\x12\x12\n\x0eLOCK_GLOVE_BOX\x10\r\x12\x13\n\x0fLOCK_GEAR_GUARD\x10\x0e\x12\x10\n\x0cLOCK_TONNEAU\x10\x0f*z\n\tLockState\x12\x1a\n\x16LOCK_STATE_UNSPECIFIED\x10\x00\x12\x15\n\x11LOCK_STATE_LOCKED\x10\x01\x12\x17\n\x13LOCK_STATE_UNLOCKED\x10\x02\x12!\n\x1dLOCK_STATE_PARTIALLY_UNLOCKED\x10\x03*?\n\tLockFault\x12\x1a\n\x16LOCK_FAULT_UNSPECIFIED\x10\x00\x12\x16\n\x12LOCK_FAULT_FAULTED\x10\x01*\xcb\x01\n\x0eWindowInstance\x12\x1f\n\x1bWINDOW_INSTANCE_UNSPECIFIED\x10\x00\x12\x1e\n\x1aWINDOW_INSTANCE_FRONT_LEFT\x10\x01\x12\x1f\n\x1bWINDOW_INSTANCE_FRONT_RIGHT\x10\x02\x12\x1d\n\x19WINDOW_INSTANCE_REAR_LEFT\x10\x03\x12\x1e\n\x1aWINDOW_INSTANCE_REAR_RIGHT\x10\x04\x12\x18\n\x14WINDOW_INSTANCE_REAR\x10\x05*\x81\x01\n\x11\x43\x61librationStatus\x12\"\n\x1e\x43\x41LIBRATION_STATUS_UNSPECIFIED\x10\x00\x12!\n\x1d\x43\x41LIBRATION_STATUS_CALIBRATED\x10\x01\x12%\n!CALIBRATION_STATUS_NOT_CALIBRATED\x10\x02*\xd8\x01\n\x0fRearHitchStatus\x12!\n\x1dREAR_HITCH_STATUS_UNSPECIFIED\x10\x00\x12!\n\x1dREAR_HITCH_STATUS_NOT_PRESENT\x10\x01\x12\x1f\n\x1bREAR_HITCH_STATUS_ACCESSORY\x10\x02\x12\x1e\n\x1aREAR_HITCH_STATUS_TRAILER1\x10\x03\x12\x1e\n\x1aREAR_HITCH_STATUS_TRAILER2\x10\x04\x12\x1e\n\x1aREAR_HITCH_STATUS_TRAILER3\x10\x05*\xaa\x01\n\x0c\x43losureFault\x12\x1d\n\x19\x43LOSURE_FAULT_UNSPECIFIED\x10\x00\x12\x1a\n\x16\x43LOSURE_FAULT_NO_FAULT\x10\x01\x12\x19\n\x15\x43LOSURE_FAULT_GENERAL\x10\x02\x12&\n\"CLOSURE_FAULT_NO_POWERED_OPERATION\x10\x03\x12\x1c\n\x18\x43LOSURE_FAULT_OBSTRUCTED\x10\x04*\x97\x01\n\x0fTrailerPresence\x12 \n\x1cTRAILER_PRESENCE_UNSPECIFIED\x10\x00\x12\x17\n\x13TRAILER_NOT_PRESENT\x10\x01\x12\x13\n\x0fTRAILER_PRESENT\x10\x02\x12\x1f\n\x1bTRAILER_PRESENT_WITH_BRAKES\x10\x03\x12\x13\n\x0fTRAILER_INVALID\x10\x04*\x9f\x04\n\x18\x43hargePortDoorNextAction\x12\x18\n\x14\x43HARGE_PORT_DOOR_SNA\x10\x00\x12!\n\x1d\x43HARGE_PORT_DOOR_OPEN_ALLOWED\x10\x01\x12\'\n#CHARGE_PORT_DOOR_OPEN_NOT_AVAILABLE\x10\x02\x12-\n)CHARGE_PORT_DOOR_OPEN_NOT_ALLOWED_FAULTED\x10\x03\x12\x1c\n\x18\x43HARGE_PORT_DOOR_OPENING\x10\x04\x12\"\n\x1e\x43HARGE_PORT_DOOR_CLOSE_ALLOWED\x10\x05\x12;\n7CHARGE_PORT_DOOR_OBSTRUCTED_WHILE_OPENING_CLOSE_ALLOWED\x10\x06\x12:\n6CHARGE_PORT_DOOR_OBSTRUCTED_WHILE_OPENING_OPEN_ALLOWED\x10\x07\x12;\n7CHARGE_PORT_DOOR_OBSTRUCTED_WHILE_CLOSING_CLOSE_ALLOWED\x10\x08\x12(\n$CHARGE_PORT_DOOR_CLOSE_NOT_AVAILABLE\x10\t\x12.\n*CHARGE_PORT_DOOR_CLOSE_NOT_ALLOWED_FAULTED\x10\n\x12\x1c\n\x18\x43HARGE_PORT_DOOR_CLOSING\x10\x0b*\xb0\x03\n\x0f\x46runkNextAction\x12\r\n\tFRUNK_SNA\x10\x00\x12\x16\n\x12\x46RUNK_OPEN_ALLOWED\x10\x01\x12\x17\n\x13\x46RUNK_CLOSE_ALLOWED\x10\x02\x12\x11\n\rFRUNK_OPENING\x10\x03\x12\x11\n\rFRUNK_CLOSING\x10\x04\x12\x1c\n\x18\x46RUNK_OPEN_NOT_AVAILABLE\x10\x05\x12\x1d\n\x19\x46RUNK_CLOSE_NOT_AVAILABLE\x10\x06\x12\"\n\x1e\x46RUNK_OPEN_NOT_ALLOWED_FAULTED\x10\x07\x12#\n\x1f\x46RUNK_CLOSE_NOT_ALLOWED_FAULTED\x10\x08\x12)\n%FRUNK_OPEN_ALLOWED_NO_POWER_OPERATION\x10\t\x12.\n*FRUNK_CLOSE_NOT_ALLOWED_NO_POWER_OPERATION\x10\n\x12*\n&FRUNK_OBSTRUCTED_OPENING_CLOSE_ALLOWED\x10\x0b\x12*\n&FRUNK_OBSTRUCTED_CLOSING_CLOSE_ALLOWED\x10\x0c*\xa7\x06\n\x12LiftgateNextAction\x12\x10\n\x0cLIFTGATE_SNA\x10\x00\x12\x19\n\x15LIFTGATE_OPEN_ALLOWED\x10\x01\x12\x1a\n\x16LIFTGATE_CLOSE_ALLOWED\x10\x02\x12\x14\n\x10LIFTGATE_OPENING\x10\x03\x12\x14\n\x10LIFTGATE_CLOSING\x10\x04\x12\x1f\n\x1bLIFTGATE_OPEN_NOT_AVAILABLE\x10\x05\x12 \n\x1cLIFTGATE_CLOSE_NOT_AVAILABLE\x10\x06\x12%\n!LIFTGATE_OPEN_NOT_ALLOWED_FAULTED\x10\x07\x12&\n\"LIFTGATE_CLOSE_NOT_ALLOWED_FAULTED\x10\x08\x12,\n(LIFTGATE_OPEN_ALLOWED_NO_POWER_OPERATION\x10\t\x12\x31\n-LIFTGATE_CLOSE_NOT_ALLOWED_NO_POWER_OPERATION\x10\n\x12-\n)LIFTGATE_OBSTRUCTED_OPENING_CLOSE_ALLOWED\x10\x0b\x12-\n)LIFTGATE_OBSTRUCTED_CLOSING_CLOSE_ALLOWED\x10\x0c\x12.\n*LIFTGATE_LOWER_GATE_OPEN_CLOSE_NOT_ALLOWED\x10\r\x12&\n\"LIFTGATE_OPENING_PAUSE_NOT_ALLOWED\x10\x0e\x12&\n\"LIFTGATE_CLOSING_PAUSE_NOT_ALLOWED\x10\x0f\x12+\n\'LIFTGATE_OPEN_ALLOWED_OBSTACLE_DETECTED\x10\x10\x12*\n&LIFTGATE_OPEN_ALLOWED_TRAILER_DETECTED\x10\x11\x12,\n(LIFTGATE_CLOSE_ALLOWED_OBSTACLE_DETECTED\x10\x12\x12+\n\'LIFTGATE_CLOSE_ALLOWED_TRAILER_DETECTED\x10\x13\x12\x17\n\x13LIFTGATE_PROCESSING\x10\x14*\xb2\x02\n\x11SideBinNextAction\x12\x10\n\x0cSIDE_BIN_SNA\x10\x00\x12\x19\n\x15SIDE_BIN_OPEN_ALLOWED\x10\x01\x12\x14\n\x10SIDE_BIN_OPENING\x10\x02\x12\x1f\n\x1bSIDE_BIN_OPEN_NOT_AVAILABLE\x10\x03\x12%\n!SIDE_BIN_OPEN_NOT_ALLOWED_FAULTED\x10\x04\x12\x32\n.SIDE_BIN_STUCK_AJAR_WHILE_OPENING_OPEN_ALLOWED\x10\x05\x12-\n)SIDE_BIN_OPEN_ALREADY_NO_ACTION_AVAILABLE\x10\x06\x12/\n+SIDE_BIN_OPEN_ALLOWED_CONFIRM_VEHICLE_ANGLE\x10\x07*\x8c\x03\n\x12TailgateNextAction\x12\x10\n\x0cTAILGATE_SNA\x10\x00\x12\x19\n\x15TAILGATE_OPEN_ALLOWED\x10\x01\x12\x14\n\x10TAILGATE_OPENING\x10\x02\x12\x1f\n\x1bTAILGATE_OPEN_NOT_AVAILABLE\x10\x03\x12%\n!TAILGATE_OPEN_NOT_ALLOWED_FAULTED\x10\x04\x12\x32\n.TAILGATE_STUCK_AJAR_WHILE_OPENING_OPEN_ALLOWED\x10\x05\x12-\n)TAILGATE_OPEN_ALREADY_NO_ACTION_AVAILABLE\x10\x06\x12/\n+TAILGATE_OPEN_ALLOWED_CONFIRM_VEHICLE_ANGLE\x10\x07\x12+\n\'TAILGATE_OPEN_ALLOWED_OBSTACLE_DETECTED\x10\x08\x12*\n&TAILGATE_OPEN_ALLOWED_TRAILER_DETECTED\x10\t*\xb0\x03\n\x11WindowsNextAction\x12\x0f\n\x0bWINDOWS_SNA\x10\x00\x12\x18\n\x14WINDOWS_OPEN_ALLOWED\x10\x01\x12\x19\n\x15WINDOWS_CLOSE_ALLOWED\x10\x02\x12\x13\n\x0fWINDOWS_OPENING\x10\x03\x12\x13\n\x0fWINDOWS_CLOSING\x10\x04\x12\x12\n\x0eWINDOWS_MOVING\x10\x05\x12\x1e\n\x1aWINDOWS_OPEN_NOT_AVAILABLE\x10\x06\x12\x1f\n\x1bWINDOWS_CLOSE_NOT_AVAILABLE\x10\x07\x12$\n WINDOWS_OPEN_NOT_ALLOWED_FAULTED\x10\x08\x12%\n!WINDOWS_CLOSE_NOT_ALLOWED_FAULTED\x10\t\x12\x32\n.WINDOWS_OBSTRUCTED_WHILE_CLOSING_CLOSE_ALLOWED\x10\n\x12*\n&WINDOWS_CLOSE_NOT_ALLOWED_UNCALIBRATED\x10\x0b\x12)\n%WINDOWS_OPEN_NOT_ALLOWED_UNCALIBRATED\x10\x0c*\x8a\x01\n\x0fWiperFluidState\x12!\n\x1dWIPER_FLUID_STATE_UNSPECIFIED\x10\x00\x12\x1c\n\x18WIPER_FLUID_STATE_NORMAL\x10\x01\x12\x19\n\x15WIPER_FLUID_STATE_LOW\x10\x02\x12\x1b\n\x17WIPER_FLUID_STATE_EMPTY\x10\x03\x62\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'rivian.parallax.proto.body_pb2', _globals) if not _descriptor._USE_C_DESCRIPTORS: DESCRIPTOR._loaded_options = None - _globals['_CLOSUREID']._serialized_start=1512 - _globals['_CLOSUREID']._serialized_end=1872 - _globals['_CLOSURESTATE']._serialized_start=1875 - _globals['_CLOSURESTATE']._serialized_end=2048 - _globals['_LOCKID']._serialized_start=2051 - _globals['_LOCKID']._serialized_end=2422 - _globals['_LOCKSTATE']._serialized_start=2424 - _globals['_LOCKSTATE']._serialized_end=2546 - _globals['_LOCKFAULT']._serialized_start=2548 - _globals['_LOCKFAULT']._serialized_end=2611 - _globals['_WINDOWINSTANCE']._serialized_start=2614 - _globals['_WINDOWINSTANCE']._serialized_end=2817 - _globals['_CALIBRATIONSTATUS']._serialized_start=2820 - _globals['_CALIBRATIONSTATUS']._serialized_end=2949 - _globals['_REARHITCHSTATUS']._serialized_start=2952 - _globals['_REARHITCHSTATUS']._serialized_end=3168 - _globals['_CLOSUREFAULT']._serialized_start=3171 - _globals['_CLOSUREFAULT']._serialized_end=3341 - _globals['_TRAILERPRESENCE']._serialized_start=3344 - _globals['_TRAILERPRESENCE']._serialized_end=3495 - _globals['_CHARGEPORTDOORNEXTACTION']._serialized_start=3498 - _globals['_CHARGEPORTDOORNEXTACTION']._serialized_end=4041 - _globals['_FRUNKNEXTACTION']._serialized_start=4044 - _globals['_FRUNKNEXTACTION']._serialized_end=4476 - _globals['_LIFTGATENEXTACTION']._serialized_start=4479 - _globals['_LIFTGATENEXTACTION']._serialized_end=5286 - _globals['_SIDEBINNEXTACTION']._serialized_start=5289 - _globals['_SIDEBINNEXTACTION']._serialized_end=5595 - _globals['_TAILGATENEXTACTION']._serialized_start=5598 - _globals['_TAILGATENEXTACTION']._serialized_end=5994 - _globals['_WINDOWSNEXTACTION']._serialized_start=5997 - _globals['_WINDOWSNEXTACTION']._serialized_end=6429 - _globals['_WIPERFLUIDSTATE']._serialized_start=6432 - _globals['_WIPERFLUIDSTATE']._serialized_end=6570 + _globals['_CLOSUREID']._serialized_start=1596 + _globals['_CLOSUREID']._serialized_end=1956 + _globals['_CLOSURESTATE']._serialized_start=1959 + _globals['_CLOSURESTATE']._serialized_end=2132 + _globals['_LOCKID']._serialized_start=2135 + _globals['_LOCKID']._serialized_end=2506 + _globals['_LOCKSTATE']._serialized_start=2508 + _globals['_LOCKSTATE']._serialized_end=2630 + _globals['_LOCKFAULT']._serialized_start=2632 + _globals['_LOCKFAULT']._serialized_end=2695 + _globals['_WINDOWINSTANCE']._serialized_start=2698 + _globals['_WINDOWINSTANCE']._serialized_end=2901 + _globals['_CALIBRATIONSTATUS']._serialized_start=2904 + _globals['_CALIBRATIONSTATUS']._serialized_end=3033 + _globals['_REARHITCHSTATUS']._serialized_start=3036 + _globals['_REARHITCHSTATUS']._serialized_end=3252 + _globals['_CLOSUREFAULT']._serialized_start=3255 + _globals['_CLOSUREFAULT']._serialized_end=3425 + _globals['_TRAILERPRESENCE']._serialized_start=3428 + _globals['_TRAILERPRESENCE']._serialized_end=3579 + _globals['_CHARGEPORTDOORNEXTACTION']._serialized_start=3582 + _globals['_CHARGEPORTDOORNEXTACTION']._serialized_end=4125 + _globals['_FRUNKNEXTACTION']._serialized_start=4128 + _globals['_FRUNKNEXTACTION']._serialized_end=4560 + _globals['_LIFTGATENEXTACTION']._serialized_start=4563 + _globals['_LIFTGATENEXTACTION']._serialized_end=5370 + _globals['_SIDEBINNEXTACTION']._serialized_start=5373 + _globals['_SIDEBINNEXTACTION']._serialized_end=5679 + _globals['_TAILGATENEXTACTION']._serialized_start=5682 + _globals['_TAILGATENEXTACTION']._serialized_end=6078 + _globals['_WINDOWSNEXTACTION']._serialized_start=6081 + _globals['_WINDOWSNEXTACTION']._serialized_end=6513 + _globals['_WIPERFLUIDSTATE']._serialized_start=6516 + _globals['_WIPERFLUIDSTATE']._serialized_end=6654 _globals['_CLOSURESSTATE']._serialized_start=59 _globals['_CLOSURESSTATE']._serialized_end=836 _globals['_CLOSURESSTATE_CLOSURE']._serialized_start=139 @@ -81,4 +81,8 @@ _globals['_TRAILERSTATE']._serialized_end=1436 _globals['_WIPERFLUIDLEVEL']._serialized_start=1438 _globals['_WIPERFLUIDLEVEL']._serialized_end=1509 + _globals['_UNLOCKCOMMAND']._serialized_start=1511 + _globals['_UNLOCKCOMMAND']._serialized_end=1578 + _globals['_LOCKCOMMAND']._serialized_start=1580 + _globals['_LOCKCOMMAND']._serialized_end=1593 # @@protoc_insertion_point(module_scope) diff --git a/src/rivian/parallax/proto/body_pb2.pyi b/src/rivian/parallax/proto/body_pb2.pyi index 49c5bce..fca5a8f 100644 --- a/src/rivian/parallax/proto/body_pb2.pyi +++ b/src/rivian/parallax/proto/body_pb2.pyi @@ -434,3 +434,15 @@ class WiperFluidLevel(_message.Message): STATE_FIELD_NUMBER: _ClassVar[int] state: WiperFluidState def __init__(self, state: _Optional[_Union[WiperFluidState, str]] = ...) -> None: ... + +class UnlockCommand(_message.Message): + __slots__ = ("all", "user_preference") + ALL_FIELD_NUMBER: _ClassVar[int] + USER_PREFERENCE_FIELD_NUMBER: _ClassVar[int] + all: bool + user_preference: bool + def __init__(self, all: bool = ..., user_preference: bool = ...) -> None: ... + +class LockCommand(_message.Message): + __slots__ = () + def __init__(self) -> None: ... diff --git a/src/rivian/parallax/proto/command.proto b/src/rivian/parallax/proto/command.proto new file mode 100644 index 0000000..a2d5582 --- /dev/null +++ b/src/rivian/parallax/proto/command.proto @@ -0,0 +1,108 @@ +syntax = "proto3"; + +package rivian.parallax.command; + +// Reverse-engineered `sendParallaxPayload` request for a signed remote command, +// and the `vehicleCommandStatePx` message that reports on it; see ../command.py. +// Only the field numbers matter on the wire. Enum values are named as the app +// names them, except ResponseStatus, which the app names without a prefix. Unlike +// operation.proto's Operation, this one goes to the vehicle inside a signed +// Envelope. + +// Wraps an Operation. Field 17 is what makes it a signed command. +message Envelope { + // The app's envelope can hold other kinds of value; a command only uses this + oneof value { + Composite composite = 10; + } + Timestamp last_update = 12; + bytes correlation_id = 16; // the command id: 16 bytes of a UUID + SecurityInfo security_info = 17; +} + +message Composite { + bytes bytes_val = 2; // a serialized Operation +} + +message Operation { + string rvm = 1; + OperationType type = 2; + bytes command_id = 3; // the same 16 bytes as the envelope's correlation_id + bytes payload = 4; // the RVM's message; absent when empty + Timestamp created_at = 5; + // 6 is never set by the app + string vehicle_id = 7; +} + +enum OperationType { + PARALLAX_OPERATION_UNSPECIFIED = 0; + PARALLAX_OPERATION_PUBLISH = 1; + PARALLAX_OPERATION_SUBSCRIBE = 2; + PARALLAX_OPERATION_REQUEST = 3; // the only one a command uses +} + +message SecurityInfo { + Signature signature = 1; + string mapped_identity = 2; // the enrolled phone's identityId for the vehicle +} + +message Signature { + SignatureType type = 1; + bytes value = 2; // 32 raw bytes of the HMAC-SHA256, not hex +} + +enum SignatureType { + DATA_SIGNATURE_TYPE_UNSPECIFIED = 0; + DATA_SIGNATURE_TYPE_HMAC_SHA256 = 1; + DATA_SIGNATURE_TYPE_RCS = 2; +} + +message Timestamp { + int64 seconds = 1; // epoch seconds + int32 nanos = 2; +} + +// What `vehicleCommandStatePx` sends, base64 encoded, as the command goes on. +message CommandState { + bytes command_id = 1; // 16 bytes of a UUID + string rvm = 2; + Progress progress = 3; + oneof result { + OperationError operation_error = 4; + // The app declares this as bytes holding a CommandResult, which is the + // same on the wire. Present, but empty, for an undefined status. + CommandResult data = 5; + } +} + +enum Progress { + VEHICLE_MODEL_OP_PROGRESS_UNSPECIFIED = 0; + VEHICLE_MODEL_OP_PROGRESS_VEHICLE_WAKING_UP = 1; // seen twice for one command + VEHICLE_MODEL_OP_PROGRESS_VEHICLE_VALIDATING = 2; + VEHICLE_MODEL_OP_PROGRESS_VEHICLE_EXECUTING = 3; + VEHICLE_MODEL_OP_PROGRESS_VEHICLE_COMPLETE = 4; // the last message +} + +message CommandResult { + ResponseStatus response_status = 1; + Error error = 2; +} + +enum ResponseStatus { + RESPONSE_STATUS_UNDEFINED = 0; + RESPONSE_STATUS_SUCCESS = 1; + RESPONSE_STATUS_FAIL = 2; + RESPONSE_STATUS_INVALID_REQUEST = 3; + RESPONSE_STATUS_NOT_SUPPORTED = 4; + RESPONSE_STATUS_TIMEOUT = 5; +} + +message Error { + int32 code = 1; + string message = 2; +} + +message OperationError { + int32 error = 1; // an enum in the app, whose values were not decompiled + string message = 2; +} diff --git a/src/rivian/parallax/proto/command_pb2.py b/src/rivian/parallax/proto/command_pb2.py new file mode 100644 index 0000000..ad1cf84 --- /dev/null +++ b/src/rivian/parallax/proto/command_pb2.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: rivian/parallax/proto/command.proto +# Protobuf Python Version: 6.31.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 31, + 1, + '', + 'rivian/parallax/proto/command.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n#rivian/parallax/proto/command.proto\x12\x17rivian.parallax.command\"\xdb\x01\n\x08\x45nvelope\x12\x37\n\tcomposite\x18\n \x01(\x0b\x32\".rivian.parallax.command.CompositeH\x00\x12\x37\n\x0blast_update\x18\x0c \x01(\x0b\x32\".rivian.parallax.command.Timestamp\x12\x16\n\x0e\x63orrelation_id\x18\x10 \x01(\x0c\x12<\n\rsecurity_info\x18\x11 \x01(\x0b\x32%.rivian.parallax.command.SecurityInfoB\x07\n\x05value\"\x1e\n\tComposite\x12\x11\n\tbytes_val\x18\x02 \x01(\x0c\"\xbf\x01\n\tOperation\x12\x0b\n\x03rvm\x18\x01 \x01(\t\x12\x34\n\x04type\x18\x02 \x01(\x0e\x32&.rivian.parallax.command.OperationType\x12\x12\n\ncommand_id\x18\x03 \x01(\x0c\x12\x0f\n\x07payload\x18\x04 \x01(\x0c\x12\x36\n\ncreated_at\x18\x05 \x01(\x0b\x32\".rivian.parallax.command.Timestamp\x12\x12\n\nvehicle_id\x18\x07 \x01(\t\"^\n\x0cSecurityInfo\x12\x35\n\tsignature\x18\x01 \x01(\x0b\x32\".rivian.parallax.command.Signature\x12\x17\n\x0fmapped_identity\x18\x02 \x01(\t\"P\n\tSignature\x12\x34\n\x04type\x18\x01 \x01(\x0e\x32&.rivian.parallax.command.SignatureType\x12\r\n\x05value\x18\x02 \x01(\x0c\"+\n\tTimestamp\x12\x0f\n\x07seconds\x18\x01 \x01(\x03\x12\r\n\x05nanos\x18\x02 \x01(\x05\"\xea\x01\n\x0c\x43ommandState\x12\x12\n\ncommand_id\x18\x01 \x01(\x0c\x12\x0b\n\x03rvm\x18\x02 \x01(\t\x12\x33\n\x08progress\x18\x03 \x01(\x0e\x32!.rivian.parallax.command.Progress\x12\x42\n\x0foperation_error\x18\x04 \x01(\x0b\x32\'.rivian.parallax.command.OperationErrorH\x00\x12\x36\n\x04\x64\x61ta\x18\x05 \x01(\x0b\x32&.rivian.parallax.command.CommandResultH\x00\x42\x08\n\x06result\"\x80\x01\n\rCommandResult\x12@\n\x0fresponse_status\x18\x01 \x01(\x0e\x32\'.rivian.parallax.command.ResponseStatus\x12-\n\x05\x65rror\x18\x02 \x01(\x0b\x32\x1e.rivian.parallax.command.Error\"&\n\x05\x45rror\x12\x0c\n\x04\x63ode\x18\x01 \x01(\x05\x12\x0f\n\x07message\x18\x02 \x01(\t\"0\n\x0eOperationError\x12\r\n\x05\x65rror\x18\x01 \x01(\x05\x12\x0f\n\x07message\x18\x02 \x01(\t*\x95\x01\n\rOperationType\x12\"\n\x1ePARALLAX_OPERATION_UNSPECIFIED\x10\x00\x12\x1e\n\x1aPARALLAX_OPERATION_PUBLISH\x10\x01\x12 \n\x1cPARALLAX_OPERATION_SUBSCRIBE\x10\x02\x12\x1e\n\x1aPARALLAX_OPERATION_REQUEST\x10\x03*v\n\rSignatureType\x12#\n\x1f\x44\x41TA_SIGNATURE_TYPE_UNSPECIFIED\x10\x00\x12#\n\x1f\x44\x41TA_SIGNATURE_TYPE_HMAC_SHA256\x10\x01\x12\x1b\n\x17\x44\x41TA_SIGNATURE_TYPE_RCS\x10\x02*\xf9\x01\n\x08Progress\x12)\n%VEHICLE_MODEL_OP_PROGRESS_UNSPECIFIED\x10\x00\x12/\n+VEHICLE_MODEL_OP_PROGRESS_VEHICLE_WAKING_UP\x10\x01\x12\x30\n,VEHICLE_MODEL_OP_PROGRESS_VEHICLE_VALIDATING\x10\x02\x12/\n+VEHICLE_MODEL_OP_PROGRESS_VEHICLE_EXECUTING\x10\x03\x12.\n*VEHICLE_MODEL_OP_PROGRESS_VEHICLE_COMPLETE\x10\x04*\xcb\x01\n\x0eResponseStatus\x12\x1d\n\x19RESPONSE_STATUS_UNDEFINED\x10\x00\x12\x1b\n\x17RESPONSE_STATUS_SUCCESS\x10\x01\x12\x18\n\x14RESPONSE_STATUS_FAIL\x10\x02\x12#\n\x1fRESPONSE_STATUS_INVALID_REQUEST\x10\x03\x12!\n\x1dRESPONSE_STATUS_NOT_SUPPORTED\x10\x04\x12\x1b\n\x17RESPONSE_STATUS_TIMEOUT\x10\x05\x62\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'rivian.parallax.proto.command_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_OPERATIONTYPE']._serialized_start=1194 + _globals['_OPERATIONTYPE']._serialized_end=1343 + _globals['_SIGNATURETYPE']._serialized_start=1345 + _globals['_SIGNATURETYPE']._serialized_end=1463 + _globals['_PROGRESS']._serialized_start=1466 + _globals['_PROGRESS']._serialized_end=1715 + _globals['_RESPONSESTATUS']._serialized_start=1718 + _globals['_RESPONSESTATUS']._serialized_end=1921 + _globals['_ENVELOPE']._serialized_start=65 + _globals['_ENVELOPE']._serialized_end=284 + _globals['_COMPOSITE']._serialized_start=286 + _globals['_COMPOSITE']._serialized_end=316 + _globals['_OPERATION']._serialized_start=319 + _globals['_OPERATION']._serialized_end=510 + _globals['_SECURITYINFO']._serialized_start=512 + _globals['_SECURITYINFO']._serialized_end=606 + _globals['_SIGNATURE']._serialized_start=608 + _globals['_SIGNATURE']._serialized_end=688 + _globals['_TIMESTAMP']._serialized_start=690 + _globals['_TIMESTAMP']._serialized_end=733 + _globals['_COMMANDSTATE']._serialized_start=736 + _globals['_COMMANDSTATE']._serialized_end=970 + _globals['_COMMANDRESULT']._serialized_start=973 + _globals['_COMMANDRESULT']._serialized_end=1101 + _globals['_ERROR']._serialized_start=1103 + _globals['_ERROR']._serialized_end=1141 + _globals['_OPERATIONERROR']._serialized_start=1143 + _globals['_OPERATIONERROR']._serialized_end=1191 +# @@protoc_insertion_point(module_scope) diff --git a/src/rivian/parallax/proto/command_pb2.pyi b/src/rivian/parallax/proto/command_pb2.pyi new file mode 100644 index 0000000..a2d78f9 --- /dev/null +++ b/src/rivian/parallax/proto/command_pb2.pyi @@ -0,0 +1,151 @@ +from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from collections.abc import Mapping as _Mapping +from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class OperationType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + PARALLAX_OPERATION_UNSPECIFIED: _ClassVar[OperationType] + PARALLAX_OPERATION_PUBLISH: _ClassVar[OperationType] + PARALLAX_OPERATION_SUBSCRIBE: _ClassVar[OperationType] + PARALLAX_OPERATION_REQUEST: _ClassVar[OperationType] + +class SignatureType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + DATA_SIGNATURE_TYPE_UNSPECIFIED: _ClassVar[SignatureType] + DATA_SIGNATURE_TYPE_HMAC_SHA256: _ClassVar[SignatureType] + DATA_SIGNATURE_TYPE_RCS: _ClassVar[SignatureType] + +class Progress(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + VEHICLE_MODEL_OP_PROGRESS_UNSPECIFIED: _ClassVar[Progress] + VEHICLE_MODEL_OP_PROGRESS_VEHICLE_WAKING_UP: _ClassVar[Progress] + VEHICLE_MODEL_OP_PROGRESS_VEHICLE_VALIDATING: _ClassVar[Progress] + VEHICLE_MODEL_OP_PROGRESS_VEHICLE_EXECUTING: _ClassVar[Progress] + VEHICLE_MODEL_OP_PROGRESS_VEHICLE_COMPLETE: _ClassVar[Progress] + +class ResponseStatus(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + RESPONSE_STATUS_UNDEFINED: _ClassVar[ResponseStatus] + RESPONSE_STATUS_SUCCESS: _ClassVar[ResponseStatus] + RESPONSE_STATUS_FAIL: _ClassVar[ResponseStatus] + RESPONSE_STATUS_INVALID_REQUEST: _ClassVar[ResponseStatus] + RESPONSE_STATUS_NOT_SUPPORTED: _ClassVar[ResponseStatus] + RESPONSE_STATUS_TIMEOUT: _ClassVar[ResponseStatus] +PARALLAX_OPERATION_UNSPECIFIED: OperationType +PARALLAX_OPERATION_PUBLISH: OperationType +PARALLAX_OPERATION_SUBSCRIBE: OperationType +PARALLAX_OPERATION_REQUEST: OperationType +DATA_SIGNATURE_TYPE_UNSPECIFIED: SignatureType +DATA_SIGNATURE_TYPE_HMAC_SHA256: SignatureType +DATA_SIGNATURE_TYPE_RCS: SignatureType +VEHICLE_MODEL_OP_PROGRESS_UNSPECIFIED: Progress +VEHICLE_MODEL_OP_PROGRESS_VEHICLE_WAKING_UP: Progress +VEHICLE_MODEL_OP_PROGRESS_VEHICLE_VALIDATING: Progress +VEHICLE_MODEL_OP_PROGRESS_VEHICLE_EXECUTING: Progress +VEHICLE_MODEL_OP_PROGRESS_VEHICLE_COMPLETE: Progress +RESPONSE_STATUS_UNDEFINED: ResponseStatus +RESPONSE_STATUS_SUCCESS: ResponseStatus +RESPONSE_STATUS_FAIL: ResponseStatus +RESPONSE_STATUS_INVALID_REQUEST: ResponseStatus +RESPONSE_STATUS_NOT_SUPPORTED: ResponseStatus +RESPONSE_STATUS_TIMEOUT: ResponseStatus + +class Envelope(_message.Message): + __slots__ = ("composite", "last_update", "correlation_id", "security_info") + COMPOSITE_FIELD_NUMBER: _ClassVar[int] + LAST_UPDATE_FIELD_NUMBER: _ClassVar[int] + CORRELATION_ID_FIELD_NUMBER: _ClassVar[int] + SECURITY_INFO_FIELD_NUMBER: _ClassVar[int] + composite: Composite + last_update: Timestamp + correlation_id: bytes + security_info: SecurityInfo + def __init__(self, composite: _Optional[_Union[Composite, _Mapping]] = ..., last_update: _Optional[_Union[Timestamp, _Mapping]] = ..., correlation_id: _Optional[bytes] = ..., security_info: _Optional[_Union[SecurityInfo, _Mapping]] = ...) -> None: ... + +class Composite(_message.Message): + __slots__ = ("bytes_val",) + BYTES_VAL_FIELD_NUMBER: _ClassVar[int] + bytes_val: bytes + def __init__(self, bytes_val: _Optional[bytes] = ...) -> None: ... + +class Operation(_message.Message): + __slots__ = ("rvm", "type", "command_id", "payload", "created_at", "vehicle_id") + RVM_FIELD_NUMBER: _ClassVar[int] + TYPE_FIELD_NUMBER: _ClassVar[int] + COMMAND_ID_FIELD_NUMBER: _ClassVar[int] + PAYLOAD_FIELD_NUMBER: _ClassVar[int] + CREATED_AT_FIELD_NUMBER: _ClassVar[int] + VEHICLE_ID_FIELD_NUMBER: _ClassVar[int] + rvm: str + type: OperationType + command_id: bytes + payload: bytes + created_at: Timestamp + vehicle_id: str + def __init__(self, rvm: _Optional[str] = ..., type: _Optional[_Union[OperationType, str]] = ..., command_id: _Optional[bytes] = ..., payload: _Optional[bytes] = ..., created_at: _Optional[_Union[Timestamp, _Mapping]] = ..., vehicle_id: _Optional[str] = ...) -> None: ... + +class SecurityInfo(_message.Message): + __slots__ = ("signature", "mapped_identity") + SIGNATURE_FIELD_NUMBER: _ClassVar[int] + MAPPED_IDENTITY_FIELD_NUMBER: _ClassVar[int] + signature: Signature + mapped_identity: str + def __init__(self, signature: _Optional[_Union[Signature, _Mapping]] = ..., mapped_identity: _Optional[str] = ...) -> None: ... + +class Signature(_message.Message): + __slots__ = ("type", "value") + TYPE_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + type: SignatureType + value: bytes + def __init__(self, type: _Optional[_Union[SignatureType, str]] = ..., value: _Optional[bytes] = ...) -> None: ... + +class Timestamp(_message.Message): + __slots__ = ("seconds", "nanos") + SECONDS_FIELD_NUMBER: _ClassVar[int] + NANOS_FIELD_NUMBER: _ClassVar[int] + seconds: int + nanos: int + def __init__(self, seconds: _Optional[int] = ..., nanos: _Optional[int] = ...) -> None: ... + +class CommandState(_message.Message): + __slots__ = ("command_id", "rvm", "progress", "operation_error", "data") + COMMAND_ID_FIELD_NUMBER: _ClassVar[int] + RVM_FIELD_NUMBER: _ClassVar[int] + PROGRESS_FIELD_NUMBER: _ClassVar[int] + OPERATION_ERROR_FIELD_NUMBER: _ClassVar[int] + DATA_FIELD_NUMBER: _ClassVar[int] + command_id: bytes + rvm: str + progress: Progress + operation_error: OperationError + data: CommandResult + def __init__(self, command_id: _Optional[bytes] = ..., rvm: _Optional[str] = ..., progress: _Optional[_Union[Progress, str]] = ..., operation_error: _Optional[_Union[OperationError, _Mapping]] = ..., data: _Optional[_Union[CommandResult, _Mapping]] = ...) -> None: ... + +class CommandResult(_message.Message): + __slots__ = ("response_status", "error") + RESPONSE_STATUS_FIELD_NUMBER: _ClassVar[int] + ERROR_FIELD_NUMBER: _ClassVar[int] + response_status: ResponseStatus + error: Error + def __init__(self, response_status: _Optional[_Union[ResponseStatus, str]] = ..., error: _Optional[_Union[Error, _Mapping]] = ...) -> None: ... + +class Error(_message.Message): + __slots__ = ("code", "message") + CODE_FIELD_NUMBER: _ClassVar[int] + MESSAGE_FIELD_NUMBER: _ClassVar[int] + code: int + message: str + def __init__(self, code: _Optional[int] = ..., message: _Optional[str] = ...) -> None: ... + +class OperationError(_message.Message): + __slots__ = ("error", "message") + ERROR_FIELD_NUMBER: _ClassVar[int] + MESSAGE_FIELD_NUMBER: _ClassVar[int] + error: int + message: str + def __init__(self, error: _Optional[int] = ..., message: _Optional[str] = ...) -> None: ... diff --git a/src/rivian/rivian.py b/src/rivian/rivian.py index 17e183b..73bd232 100644 --- a/src/rivian/rivian.py +++ b/src/rivian/rivian.py @@ -36,6 +36,7 @@ CLIMATE_HOLD_SETTING_RVM, CLIMATE_HOLD_STATUS_RVM, encode_climate_hold_setting, + encode_command, encode_vehicle_operation, ) from .utils import generate_vehicle_command_hmac @@ -91,6 +92,16 @@ def _iso_date(value: date | str) -> str: return value if isinstance(value, str) else value.strftime("%Y-%m-%d") +def _command_id(command_id: str | None = None) -> str: + """Return a Parallax command id as a canonical UUID string, or a new one.""" + if command_id is None: + return str(uuid.uuid4()) + try: + return str(uuid.UUID(str(command_id))) + except ValueError as err: + raise RivianBadRequestError("A command id must be a UUID") from err + + def send_deprecation_warning(old_name: str, new_name: str) -> None: # pragma: no cover """Send a deprecation warning.""" message = f"{old_name} has been deprecated in favor of {new_name}, the alias will be removed in the future" @@ -762,6 +773,72 @@ async def send_vehicle_command( return status.get("id") return None + async def send_parallax_command( + self, + vehicle_id: str, + rvm: str, + payload: bytes, + *, + identity_id: str, + vehicle_key: str, + private_key: str, + command_id: str | None = None, + ) -> str | None: + """Send a signed Parallax command to the vehicle, e.g. to unlock it. + + `rvm` and `payload` go together, e.g. `UNLOCK_RVM` and `encode_unlock()` + from `rivian.parallax`. The keys are those of `send_vehicle_command`: a + phone key enrolled with `enroll_phone`. In testing, a sleeping vehicle + was woken by the request, so `WAKE_VEHICLE` wasn't needed first. The app + sends body commands this way only to vehicles that list `PVS_BD_CMD` as + available; how others respond is untested. + + Returns the command's id, a UUID (the given `command_id`, if any), once + Rivian has accepted the command, or None if it didn't. That isn't the + command done: follow it with `subscribe_for_parallax_command_state`. + + Raises `RivianBadRequestError` for a missing id or a `command_id` that + isn't a UUID, and the usual `RivianApiException` if the request fails, + which can be after Rivian has the command. Bad keys raise `ValueError` + or a `cryptography` error before anything is sent. + """ + if not (vehicle_id and rvm and identity_id): + raise RivianBadRequestError( + "A Parallax command needs a vehicle id, an RVM and an identity id" + ) + command_id = _command_id(command_id) + url = GRAPHQL_GATEWAY + headers = BASE_HEADERS | { + "Csrf-Token": self._csrf_token, + "A-Sess": self._app_session_token, + "U-Sess": self._user_session_token, + } + graphql_json = { + "operationName": "SendRemoteCommand", + "variables": { + "vehicleId": vehicle_id, + "model": rvm, + "parallaxPayloadB64": encode_command( + vehicle_id, + rvm, + payload, + identity_id=identity_id, + vehicle_key=vehicle_key, + private_key=private_key, + command_id=command_id, + ), + }, + "query": "mutation SendRemoteCommand($vehicleId: String!, $model: String!, $parallaxPayloadB64: String!) { sendParallaxPayload(payload: $parallaxPayloadB64, meta: { vehicleId: $vehicleId model: $model isVehicleModelOp: true requiresWakeup: true } ) { success sequenceNumber } }", + } + + response = await self.__graphql_query(headers, url, graphql_json) + if response.status == 200: + data = await response.json() + sent = (data.get("data") or {}).get("sendParallaxPayload") or {} + if sent.get("success"): + return command_id + return None + async def send_vehicle_operation( self, vehicle_id: str, @@ -889,6 +966,36 @@ async def subscribe_for_parallax_messages( payload, callback, f"{vehicle_id} {topics}" ) + async def subscribe_for_parallax_command_state( + self, + vehicle_id: str, + command_id: str, + callback: Callable[[dict[str, Any]], None], + ) -> Callable[[], Awaitable[None]] | None: + """Open a web socket connection to follow a `send_parallax_command`. + + The messages report the command's progress, up to its result; decode them + with `parallax.decode_command_state_message`. Subscribe before sending the + command: Rivian doesn't acknowledge a subscription, and states from before + it may not be replayed. + + The last message has the progress "complete", but none comes if the + vehicle can't be reached, so keep a timeout of your own. The subscription + is kept, and resubscribed after a quiet minute or a reconnect (so a message + may come more than once), until the returned function is awaited. Returns + None if it couldn't subscribe, and raises `RivianBadRequestError` if + `command_id` isn't a UUID. + """ + command_id = _command_id(command_id) + payload = { + "operationName": "vehicleCommandStatePx", + "query": "subscription vehicleCommandStatePx($vehicleId: String!, $commandId: String!) { vehicleCommandStatePx(vehicleId: $vehicleId, commandId: $commandId) { payload } }", + "variables": {"vehicleId": vehicle_id, "commandId": command_id}, + } + return await self._start_subscription( + payload, callback, f"{vehicle_id} command {command_id}" + ) + async def _start_subscription( self, payload: dict[str, Any], diff --git a/tests/parallax/test_body.py b/tests/parallax/test_body.py index 8f51c81..7b52142 100644 --- a/tests/parallax/test_body.py +++ b/tests/parallax/test_body.py @@ -2,6 +2,7 @@ from __future__ import annotations +from rivian.parallax import LOCK_RVM, UNLOCK_RVM, encode_lock, encode_unlock from rivian.parallax.proto import body_pb2 as body from .helpers import decode @@ -162,3 +163,12 @@ def test_wiper_fluid_level() -> None: result = decode(rvm, body.WiperFluidLevel(state=body.WIPER_FLUID_STATE_NORMAL)) assert result == {"wiperFluidState": "normal"} assert decode(rvm) == {"wiperFluidState": None} + + +def test_lock_commands() -> None: + """Unlock sets `all`, the way the app does; lock has no fields.""" + assert LOCK_RVM == "body.locks.lock" + assert UNLOCK_RVM == "body.locks.unlock" + assert encode_lock() == b"" + assert encode_unlock() == b"\x08\x01" + assert body.UnlockCommand.FromString(encode_unlock()).WhichOneof("target") == "all" diff --git a/tests/parallax/test_command.py b/tests/parallax/test_command.py new file mode 100644 index 0000000..0ee32b8 --- /dev/null +++ b/tests/parallax/test_command.py @@ -0,0 +1,303 @@ +"""Tests for signed Parallax commands and the state messages that report on them.""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import logging +import uuid +from typing import Any +from unittest.mock import patch + +import pytest + +from rivian.parallax import ( + decode_command_state_message, + encode_command, + encode_lock, + encode_unlock, +) +from rivian.parallax.command import decode_command_state +from rivian.parallax.proto import command_pb2 as command +from rivian.utils import generate_key_pair, get_secret_key + +RVM = "body.locks.unlock" +COMMAND_ID = uuid.UUID(bytes=bytes(range(16))) +NOW_MS = 1_700_000_000_123 +# The signature the vehicle accepted on GOLDEN, injected so the test needs no keys +SIGNATURE = bytes.fromhex( + "41b86b3b3f6c5b96d09abf3f77e87af7bbdbc7ef40bed7861b112b154e5c2dd0" +) +# An unlock as app 3.17.0 sends it, captured from a request an R1T carried out in +# 2026-10 and then sanitized: the ids are made up and the signature is injected. +# It proves the framing; test_encode_command proves the signature. +GOLDEN = ( + "524512430a11626f64792e6c6f636b732e756e6c6f636b10031a10000102030405060708090a0b0c" + "0d0e0f220208012a0b0880e2cfaa0610c0a9d33a3a0976656869636c652d31620b0880e2cfaa0610" + "c0a9d33a820110000102030405060708090a0b0c0d0e0f8a01320a240801122041b86b3b3f6c5b96" + "d09abf3f77e87af7bbdbc7ef40bed7861b112b154e5c2dd0120a6964656e746974792d31" +) + + +def _keys() -> dict[str, str]: + """The arguments of encode_command that are a phone's and a vehicle's keys.""" + vehicle_key, _ = generate_key_pair() + _, private_key = generate_key_pair() + return { + "identity_id": "identity-1", + "vehicle_key": vehicle_key, + "private_key": private_key, + } + + +def _envelope(encoded: str) -> command.Envelope: + return command.Envelope.FromString(base64.b64decode(encoded)) + + +def _operation(envelope: command.Envelope) -> command.Operation: + return command.Operation.FromString(envelope.composite.bytes_val) + + +def test_encode_command_matches_a_payload_a_vehicle_accepted() -> None: + """The encoding is byte for byte what was sent to a real vehicle.""" + with patch( + "rivian.parallax.command.generate_ble_command_hmac", return_value=SIGNATURE + ): + encoded = encode_command( + "vehicle-1", + RVM, + encode_unlock(), + identity_id="identity-1", + vehicle_key="vehicle_key", + private_key="private_key", + command_id=str(COMMAND_ID), + timestamp=NOW_MS, + ) + assert base64.b64decode(encoded).hex() == GOLDEN + + +def test_encode_command() -> None: + """The operation is signed with the secret both keys derive.""" + phone_public, phone_private = generate_key_pair() + vehicle_public, vehicle_private = generate_key_pair() + + envelope = _envelope( + encode_command( + "vehicle-1", + RVM, + encode_unlock(), + identity_id="identity-1", + vehicle_key=vehicle_public, + private_key=phone_private, + command_id=str(COMMAND_ID), + timestamp=NOW_MS, + ) + ) + operation = _operation(envelope) + assert operation.rvm == RVM + assert operation.type == command.PARALLAX_OPERATION_REQUEST + assert operation.command_id == COMMAND_ID.bytes == envelope.correlation_id + assert operation.payload == encode_unlock() + assert operation.vehicle_id == "vehicle-1" + for stamp in (operation.created_at, envelope.last_update): + assert (stamp.seconds, stamp.nanos) == (1_700_000_000, 123_000_000) + + # The vehicle derives the same secret from its own key and the phone's + secret = get_secret_key(vehicle_private, phone_public) + signature = envelope.security_info.signature + assert signature.type == command.DATA_SIGNATURE_TYPE_HMAC_SHA256 + assert ( + signature.value + == hmac.new(secret, envelope.composite.bytes_val, hashlib.sha256).digest() + ) + assert envelope.security_info.mapped_identity == "identity-1" + + +def test_encode_command_defaults() -> None: + """Each command gets a new id and the time now, unless given; a lock has no payload.""" + args = ("vehicle-1", "body.locks.lock", encode_lock()) + with patch("rivian.parallax.command.time.time", return_value=1_700_000_000.5): + first = _envelope(encode_command(*args, **_keys())) + second = _envelope(encode_command(*args, **_keys())) + assert len(first.correlation_id) == 16 + assert first.correlation_id != second.correlation_id + assert uuid.UUID(bytes=first.correlation_id).version == 4 + assert (first.last_update.seconds, first.last_update.nanos) == ( + 1_700_000_000, + 500_000_000, + ) + assert _operation(first).payload == b"" + + +@pytest.mark.parametrize( + ("timestamp", "seconds", "nanos"), + [ + (1_700_000_000_999, 1_700_000_000, 999_000_000), # cut, not rounded + (0, 0, 0), # a time, not 'now' + ], +) +def test_encode_command_timestamp(timestamp: int, seconds: int, nanos: int) -> None: + """The timestamp is epoch milliseconds.""" + stamp = _envelope( + encode_command("vehicle-1", RVM, b"", **_keys(), timestamp=timestamp) + ).last_update + assert (stamp.seconds, stamp.nanos) == (seconds, nanos) + + +@pytest.mark.parametrize("command_id", ["not-a-uuid", ""]) +def test_encode_command_with_an_invalid_command_id(command_id: str) -> None: + """The id must be a UUID, even an empty one.""" + with pytest.raises(ValueError): + encode_command("vehicle-1", RVM, b"", **_keys(), command_id=command_id) + + +def _state( + progress: int = command.VEHICLE_MODEL_OP_PROGRESS_VEHICLE_COMPLETE, + command_id: bytes = COMMAND_ID.bytes, + **result: Any, +) -> str: + """The payload of a `vehicleCommandStatePx` message.""" + message = command.CommandState( + command_id=command_id, rvm=RVM, progress=progress, **result + ) + return base64.b64encode(message.SerializeToString()).decode() + + +def _complete(**result: Any) -> str: + return _state(data=command.CommandResult(**result)) + + +@pytest.mark.parametrize( + ("progress", "expected"), + [ + (command.VEHICLE_MODEL_OP_PROGRESS_UNSPECIFIED, "unspecified"), + (command.VEHICLE_MODEL_OP_PROGRESS_VEHICLE_WAKING_UP, "waking_up"), + (command.VEHICLE_MODEL_OP_PROGRESS_VEHICLE_VALIDATING, "validating"), + (command.VEHICLE_MODEL_OP_PROGRESS_VEHICLE_EXECUTING, "executing"), + (command.VEHICLE_MODEL_OP_PROGRESS_VEHICLE_COMPLETE, "complete"), + ], +) +def test_decode_progress(progress: int, expected: str) -> None: + """Before the result there is a progress and no status.""" + assert decode_command_state(_state(progress)) == { + "commandId": str(COMMAND_ID), + "rvm": RVM, + "progress": expected, + "status": None, + "error": None, + } + + +@pytest.mark.parametrize( + ("status", "expected"), + [ + (command.RESPONSE_STATUS_UNDEFINED, "undefined"), + (command.RESPONSE_STATUS_SUCCESS, "success"), + (command.RESPONSE_STATUS_FAIL, "failed"), + (command.RESPONSE_STATUS_INVALID_REQUEST, "invalid_request"), + (command.RESPONSE_STATUS_NOT_SUPPORTED, "not_supported"), + (command.RESPONSE_STATUS_TIMEOUT, "timeout"), + ], +) +def test_decode_status(status: int, expected: str) -> None: + """A result has a status, even the default one that protobuf leaves out.""" + result = decode_command_state(_complete(response_status=status)) + assert result is not None + assert result["progress"] == "complete" + assert result["status"] == expected + assert result["error"] is None + + +def test_decode_result_error() -> None: + """The result's own error is kept, with the status.""" + result = decode_command_state( + _complete( + response_status=command.RESPONSE_STATUS_FAIL, + error=command.Error(code=22, message="not now"), + ) + ) + assert result is not None + assert result["status"] == "failed" + assert result["error"] == {"code": 22, "message": "not now"} + + +def test_decode_operation_error() -> None: + """If the whole operation failed there is an error and no status.""" + result = decode_command_state( + _state(operation_error=command.OperationError(error=7, message="bad")) + ) + assert result is not None + assert result["status"] is None + assert result["error"] == {"code": 7, "message": "bad"} + + +def test_decode_unknown_values(caplog: pytest.LogCaptureFixture) -> None: + """Values the schema doesn't know are kept as numbers, and logged.""" + caplog.set_level(logging.DEBUG, logger="rivian.parallax.core") + result = decode_command_state( + _state( + progress=9, + command_id=b"short", + data=command.CommandResult(response_status=9), + ) + ) + assert result is not None + assert result["progress"] == 9 + assert result["status"] == 9 + assert result["commandId"] is None + assert "Unknown command progress value: 9" in caplog.text + assert "Unknown command status value: 9" in caplog.text + + +def test_decode_logs_the_message(caplog: pytest.LogCaptureFixture) -> None: + """What was received is debug-logged, like for the other Parallax messages.""" + caplog.set_level(logging.DEBUG, logger="rivian.parallax.core") + decode_command_state(_state()) + assert "command state: " in caplog.text + assert "progress: VEHICLE_MODEL_OP_PROGRESS_VEHICLE_COMPLETE" in caplog.text + + +@pytest.mark.parametrize( + "payload", ["AAAA", base64.b64encode(b"\xff").decode(), "A", "", "!!!!", None] +) +def test_decode_unreadable(payload: str | None) -> None: + """A payload that is empty or isn't a state message gives nothing.""" + assert decode_command_state(payload) is None # type: ignore[arg-type] + + +def test_decode_state_message() -> None: + """The state is unwrapped from the whole websocket message.""" + message = { + "type": "next", + "id": "subscription-id", + "payload": { + "data": { + "vehicleCommandStatePx": { + "payload": _complete( + response_status=command.RESPONSE_STATUS_SUCCESS + ) + } + } + }, + } + result = decode_command_state_message(message) + assert result is not None + assert result["progress"] == "complete" + assert result["status"] == "success" + + +@pytest.mark.parametrize( + "message", + [ + {"payload": {"errors": [{"message": "no access"}]}}, # an error frame + {"payload": {"data": {"vehicleCommandStatePx": {"payload": None}}}}, + {"payload": {"data": {"vehicleCommandStatePx": {"payload": 5}}}}, + {"payload": {"data": [1]}}, + {"payload": {"data": {"vehicleCommandStatePx": "text"}}}, + None, + ], +) +def test_decode_state_message_without_a_state(message: Any) -> None: + """Error frames and the like give nothing.""" + assert decode_command_state_message(message) is None diff --git a/tests/responses.py b/tests/responses.py index 9e3e42a..91b0d3e 100644 --- a/tests/responses.py +++ b/tests/responses.py @@ -647,6 +647,10 @@ } DEPART_NOW_RESPONSE = {"data": {"departNow": {"success": True}}} +SEND_PARALLAX_COMMAND_RESPONSE = { + "data": {"sendParallaxPayload": {"success": True, "sequenceNumber": 0}} +} + SEND_VEHICLE_OPERATION_RESPONSE = { "data": { "sendVehicleOperation": { diff --git a/tests/test_rivian.py b/tests/test_rivian.py index 5267cb4..4f39d07 100644 --- a/tests/test_rivian.py +++ b/tests/test_rivian.py @@ -5,6 +5,7 @@ import base64 import json +import uuid from datetime import UTC, date, datetime from typing import Any from unittest.mock import AsyncMock, Mock @@ -28,7 +29,9 @@ RivianTemporarilyLockedError, RivianUnauthenticated, ) -from rivian.parallax.proto import operation_pb2 +from rivian.parallax import UNLOCK_RVM, encode_unlock +from rivian.parallax.proto import command_pb2, operation_pb2 +from rivian.utils import generate_key_pair from .responses import ( AUTHENTICATION_OTP_RESPONSE, @@ -38,6 +41,7 @@ DELETE_DEPARTURE_SCHEDULE_RESPONSE, DEPART_NOW_RESPONSE, OTP_TOKEN_RESPONSE, + SEND_PARALLAX_COMMAND_RESPONSE, SEND_VEHICLE_OPERATION_RESPONSE, SET_CHARGING_SCHEDULES_RESPONSE, SKIP_NEXT_SCHEDULED_DEPARTURE_RESPONSE, @@ -443,6 +447,206 @@ async def test_subscribe_for_departure_schedules() -> None: ) +SEND_REMOTE_COMMAND_MUTATION = ( + "mutation SendRemoteCommand($vehicleId: String!, $model: String!, " + "$parallaxPayloadB64: String!) { sendParallaxPayload(" + "payload: $parallaxPayloadB64, meta: { vehicleId: $vehicleId model: $model " + "isVehicleModelOp: true requiresWakeup: true } ) { success sequenceNumber } }" +) +COMMAND_ID = "00010203-0405-0607-0809-0a0b0c0d0e0f" + + +def decode_parallax_command( + request: dict[str, Any], +) -> tuple[command_pb2.Envelope, command_pb2.Operation]: + """Decode the envelope and operation of a sendParallaxPayload request.""" + payload = base64.b64decode(request["variables"]["parallaxPayloadB64"]) + envelope = command_pb2.Envelope.FromString(payload) + return envelope, command_pb2.Operation.FromString(envelope.composite.bytes_val) + + +async def send_unlock(rivian: Rivian, **overrides: Any) -> str | None: + """Send an unlock with a phone key and a vehicle key, or what overrides them.""" + vehicle_key, _ = generate_key_pair() + _, private_key = generate_key_pair() + args: dict[str, Any] = { + "vehicle_id": "vehicle_id", + "rvm": UNLOCK_RVM, + "payload": encode_unlock(), + "identity_id": "identity_id", + "vehicle_key": vehicle_key, + "private_key": private_key, + } + return await rivian.send_parallax_command(**(args | overrides)) + + +def parallax_rivian() -> Rivian: + """A client that can send commands.""" + return Rivian(csrf_token="csrf", app_session_token="app", user_session_token="user") + + +async def test_send_parallax_command(aresponses: ResponsesMockServer) -> None: + """Test sending a signed Parallax command.""" + requests = capture_requests( + aresponses, + SEND_PARALLAX_COMMAND_RESPONSE, + SEND_PARALLAX_COMMAND_RESPONSE, + {"data": {"sendParallaxPayload": {"success": False, "sequenceNumber": 0}}}, + {"data": {"sendParallaxPayload": None}}, + {"data": None}, + ) + async with aiohttp.ClientSession(): + rivian = parallax_rivian() + + # Any form of a UUID is accepted, and returned in its canonical form + assert await send_unlock(rivian, command_id=COMMAND_ID.upper()) == COMMAND_ID + assert requests[0]["operationName"] == "SendRemoteCommand" + assert requests[0]["query"] == SEND_REMOTE_COMMAND_MUTATION + assert requests[0]["variables"]["vehicleId"] == "vehicle_id" + assert requests[0]["variables"]["model"] == UNLOCK_RVM + envelope, operation = decode_parallax_command(requests[0]) + assert operation.rvm == UNLOCK_RVM + assert operation.payload == encode_unlock() + assert operation.vehicle_id == "vehicle_id" + assert uuid.UUID(bytes=operation.command_id) == uuid.UUID(COMMAND_ID) + assert envelope.security_info.mapped_identity == "identity_id" + + # Without an id, a new one is made and returned + new_id = await send_unlock(rivian) + assert new_id is not None + assert uuid.UUID(new_id).version == 4 + envelope, _ = decode_parallax_command(requests[1]) + assert uuid.UUID(bytes=envelope.correlation_id) == uuid.UUID(new_id) + + # Not accepted, or no answer + for _ in range(3): + assert await send_unlock(rivian) is None + assert len(requests) == 5 + await rivian.close() + + +async def test_send_parallax_command_request_headers( + aresponses: ResponsesMockServer, +) -> None: + """Test that a Parallax command is sent with the session's headers.""" + headers: dict[str, str] = {} + + async def handler(request: aiohttp.web.Request) -> aiohttp.web.Response: + headers.update(request.headers) + return aresponses.Response( + text=json.dumps(SEND_PARALLAX_COMMAND_RESPONSE), + content_type="application/json", + ) + + aresponses.add("rivian.com", "/api/gql/gateway/graphql", "POST", response=handler) + async with aiohttp.ClientSession(): + rivian = parallax_rivian() + assert await send_unlock(rivian) + assert headers["Csrf-Token"] == "csrf" + assert headers["A-Sess"] == "app" + assert headers["U-Sess"] == "user" + await rivian.close() + + +async def test_send_parallax_command_error_response( + aresponses: ResponsesMockServer, +) -> None: + """Test that only an accepted command, in a successful response, is returned.""" + for status in (202, 500): + aresponses.add( + "rivian.com", + "/api/gql/gateway/graphql", + "POST", + response=aresponses.Response( + status=status, + text=json.dumps(SEND_PARALLAX_COMMAND_RESPONSE), + content_type="application/json", + ), + ) + async with aiohttp.ClientSession(): + rivian = parallax_rivian() + for _ in range(2): + assert await send_unlock(rivian) is None + await rivian.close() + + +@pytest.mark.parametrize( + "overrides", + [ + {"command_id": ""}, + {"command_id": "not-a-uuid"}, + {"command_id": b"0" * 16}, + {"command_id": 5}, + {"vehicle_id": ""}, + {"rvm": ""}, + {"identity_id": ""}, + ], +) +async def test_send_parallax_command_refuses_bad_arguments( + aresponses: ResponsesMockServer, overrides: dict[str, Any] +) -> None: + """Test that a bad command id, or a missing id, is refused before sending.""" + requests = capture_requests(aresponses, SEND_PARALLAX_COMMAND_RESPONSE) + async with aiohttp.ClientSession(): + rivian = parallax_rivian() + with pytest.raises(RivianBadRequestError): + await send_unlock(rivian, **overrides) + assert not requests + + +async def test_send_parallax_command_with_bad_keys( + aresponses: ResponsesMockServer, +) -> None: + """Test that keys that can't sign fail before anything is sent.""" + requests = capture_requests(aresponses, SEND_PARALLAX_COMMAND_RESPONSE) + async with aiohttp.ClientSession(): + rivian = parallax_rivian() + with pytest.raises(ValueError): + await send_unlock(rivian, private_key="not a key") + assert not requests + + +async def test_subscribe_for_parallax_command_state() -> None: + """Test following a Parallax command.""" + rivian = Rivian(user_session_token="token") + unsubscribe = AsyncMock() + monitor = Mock(wait_for_ack=AsyncMock(return_value=True)) + monitor.start_subscription = AsyncMock(return_value=unsubscribe) + rivian._ws_monitor = monitor + rivian._ws_connect = AsyncMock(return_value=monitor) # type: ignore[method-assign] + callback = Mock() + + # The id is subscribed to in the form it was signed in + assert ( + await rivian.subscribe_for_parallax_command_state( + "vehicle_id", COMMAND_ID.upper(), callback + ) + is unsubscribe + ) + payload, subscribed_callback = monitor.start_subscription.call_args.args + assert payload["operationName"] == "vehicleCommandStatePx" + assert payload["query"] == ( + "subscription vehicleCommandStatePx($vehicleId: String!, " + "$commandId: String!) { vehicleCommandStatePx(vehicleId: $vehicleId, " + "commandId: $commandId) { payload } }" + ) + assert payload["variables"] == {"vehicleId": "vehicle_id", "commandId": COMMAND_ID} + assert subscribed_callback is callback + + with pytest.raises(RivianBadRequestError): + await rivian.subscribe_for_parallax_command_state( + "vehicle_id", "not-a-uuid", callback + ) + + rivian._ws_connect.side_effect = aiohttp.ClientError + assert ( + await rivian.subscribe_for_parallax_command_state( + "vehicle_id", COMMAND_ID, callback + ) + is None + ) + + def test_ws_backoff_without_a_web_socket() -> None: """Test the web socket backoff properties before anything has connected.""" rivian = Rivian()