From 3adad09108773202d37ad4fb84eb254ba665f82d Mon Sep 17 00:00:00 2001 From: Walt Howd Date: Tue, 10 Nov 2026 04:41:31 +0000 Subject: [PATCH 1/2] Pass subscription errors to an on_error callback The server ends a subscription with an `error` message when it fails, for example when the vehicle refuses a command the subscription waits on. The web socket only logged those at debug, so a caller waiting on the subscription could only time out. Subscriptions can now take an `on_error` callback that gets the error message; existing callers are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FxWHBTrHU6eTPotwMWgtJF --- src/rivian/rivian.py | 5 ++++- src/rivian/ws_monitor.py | 29 ++++++++++++++++++++++++----- tests/test_ws_monitor.py | 22 +++++++++++++++++++++- 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/rivian/rivian.py b/src/rivian/rivian.py index 0d0ebc6..ea2878f 100644 --- a/src/rivian/rivian.py +++ b/src/rivian/rivian.py @@ -1012,6 +1012,7 @@ async def _start_subscription( payload: dict[str, Any], callback: Callable[[dict[str, Any]], None], description: str, + on_error: Callable[[dict[str, Any]], None] | None = None, ) -> Callable[[], Awaitable[None]] | None: """Connect the web socket if needed and start a subscription. @@ -1022,7 +1023,9 @@ async def _start_subscription( ws_monitor = await self._ws_connect() # Ends early if the connection closes, e.g. when rate limited if await ws_monitor.wait_for_ack() and ( - unsubscribe := await ws_monitor.start_subscription(payload, callback) + unsubscribe := await ws_monitor.start_subscription( + payload, callback, on_error=on_error + ) ): _LOGGER.debug("Subscribed to %s", description) return unsubscribe diff --git a/src/rivian/ws_monitor.py b/src/rivian/ws_monitor.py index ccf0bce..c65dd1f 100644 --- a/src/rivian/ws_monitor.py +++ b/src/rivian/ws_monitor.py @@ -98,8 +98,14 @@ def __init__( self._monitor_task: asyncio.Task | None = None self._receiver_task: asyncio.Task | None = None self._last_received: datetime | None = None + # id -> (callback, payload, error callback) self._subscriptions: dict[ - str, tuple[Callable[[dict[str, Any]], None], dict[str, Any]] + str, + tuple[ + Callable[[dict[str, Any]], None], + dict[str, Any], + Callable[[dict[str, Any]], None] | None, + ], ] = {} @property @@ -211,13 +217,20 @@ async def wait_for_ack(self) -> bool: return self._connection_ack.is_set() async def start_subscription( - self, payload: dict[str, Any], callback: Callable[[dict[str, Any]], None] + self, + payload: dict[str, Any], + callback: Callable[[dict[str, Any]], None], + on_error: Callable[[dict[str, Any]], None] | None = None, ) -> Callable[[], Awaitable[None]] | None: - """Start a subscription.""" + """Start a subscription. + + `on_error` gets the subscription's error messages, which are otherwise + only logged. + """ if not self.connected: return None _id = str(uuid4()) - self._subscriptions[_id] = (callback, payload) + self._subscriptions[_id] = (callback, payload, on_error) await self._subscribe(_id, payload) async def unsubscribe() -> None: @@ -247,7 +260,7 @@ async def _resubscribe_all(self) -> None: except TimeoutError: _LOGGER.error("A timeout occurred while attempting to resubscribe") return - for _id, (_, payload) in self._subscriptions.items(): + for _id, (_, payload, _) in self._subscriptions.items(): await self._subscribe(_id, payload) async def _receiver(self) -> None: @@ -305,6 +318,12 @@ async def _receive(self, websocket: ClientWebSocketResponse[bool]) -> None: _fn(data) except Exception: # pylint: disable=broad-except _LOGGER.exception("Error in a subscription callback") + elif ( + data_type == "error" + and (subscription := self._subscriptions.get(data.get("id"))) + and subscription[2] + ): + subscription[2](data) else: self._log_message(msg) elif msg.type == WSMsgType.ERROR: diff --git a/tests/test_ws_monitor.py b/tests/test_ws_monitor.py index 3e9909b..dd5da05 100644 --- a/tests/test_ws_monitor.py +++ b/tests/test_ws_monitor.py @@ -31,6 +31,7 @@ def __init__(self) -> None: self.ping_once_connections = 0 self.connections = 0 self.next_on_subscribe = False + self.error_on_subscribe = False self.url = "" async def handler(self, request: web.Request) -> web.WebSocketResponse: @@ -72,6 +73,10 @@ async def handler(self, request: web.Request) -> web.WebSocketResponse: data = msg.json() if self.next_on_subscribe and data.get("type") == "subscribe": await ws.send_json({"id": data["id"], "type": "next"}) + if self.error_on_subscribe and data.get("type") == "subscribe": + await ws.send_json( + {"id": data["id"], "type": "error", "payload": [{}]} + ) return ws @@ -159,7 +164,7 @@ async def fake_sleep(delay: float) -> None: def add_subscription(monitor: WebSocketMonitor) -> None: """Give the monitor a subscription to resume, so it keeps reconnecting.""" - monitor._subscriptions["sub"] = (lambda _: None, {"query": "subscription"}) + monitor._subscriptions["sub"] = (lambda _: None, {"query": "subscription"}, None) async def wait_for(condition: Any, timeout: float = 5) -> None: @@ -580,3 +585,18 @@ async def connection_init(ws: ClientWebSocketResponse[bool]) -> None: assert unacked.rate_limited finally: WebSocketMonitor._shared_backoffs.clear() + + +async def test_subscription_errors_reach_on_error( + server: FakeServer, monitor: WebSocketMonitor +) -> None: + """A subscription's error message goes to its on_error, if it has one.""" + server.error_on_subscribe = True + await monitor.new_connection(start_monitor=True) + await wait_for(lambda: monitor.connection_ack.is_set()) + errors: list[dict[str, Any]] = [] + await monitor.start_subscription({"query": "a"}, lambda _: None, errors.append) + await monitor.start_subscription({"query": "b"}, lambda _: None) + await wait_for(lambda: errors) + assert errors[0]["type"] == "error" + assert monitor.connected From 8e4438bae40ae19334dc16824c637fb7e309b471 Mon Sep 17 00:00:00 2001 From: Walt Howd Date: Tue, 10 Nov 2026 04:41:31 +0000 Subject: [PATCH 2/2] Add starting a Gear Guard live stream `start_gear_guard_live_stream` starts a live camera stream the way the Rivian app does and returns its Kinesis Video WebRTC config (signaling endpoint, ICE servers and role), for a viewer to join: - It subscribes to `gearGuardLiveConfig` with a fresh command id, then sends the signed `security.video_monitoring.start_live_streaming` command with that same id; the cloud routes the config by it. - `parallax.encode_live_streaming` builds the command's payload from a new `LiveStreamingRequest` schema; `GEAR_GUARD_CAMERAS` names the cameras. - A refused start (e.g. a camera the vehicle doesn't have) raises RivianApiException straight away, through the subscription's `on_error`. It needs Gear Guard video monitoring on and a paired phone. The vehicle serves one session at a time, about 3 minutes each and an hour a day. Builds on #249. Verified against a 2026 R1S (Gen 2) running 2026.36.01: the config arrived within seconds of the start when the vehicle was awake, and the front and right cameras streamed in a WebRTC viewer. A sleeping vehicle can take longer to wake than the default 40 second timeout. Not tested: the rear, left and bed cameras, the R2's cabin camera. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FxWHBTrHU6eTPotwMWgtJF --- src/rivian/parallax/__init__.py | 10 +++ src/rivian/parallax/proto/security.proto | 16 +++++ src/rivian/parallax/proto/security_pb2.py | 46 +++++++------ src/rivian/parallax/proto/security_pb2.pyi | 25 +++++++ src/rivian/parallax/security.py | 18 +++++ src/rivian/rivian.py | 78 ++++++++++++++++++++++ tests/parallax/test_security.py | 10 +++ tests/test_rivian.py | 59 ++++++++++++++++ 8 files changed, 241 insertions(+), 21 deletions(-) diff --git a/src/rivian/parallax/__init__.py b/src/rivian/parallax/__init__.py index c5ebbd4..fc544f5 100644 --- a/src/rivian/parallax/__init__.py +++ b/src/rivian/parallax/__init__.py @@ -17,6 +17,8 @@ The RVM and payload of a signed lock or unlock command. decode_command_state_message(data) -> dict | None Reads a raw `subscribe_for_parallax_command_state` message. + encode_live_streaming(camera, device_id) -> bytes + A Gear Guard live stream start, for `start_gear_guard_live_stream`. Layout: core.py - registry, payload parsing and shared helpers @@ -65,6 +67,11 @@ decode_parallax_subscription_message, ) from .operation import encode_vehicle_operation +from .security import ( + GEAR_GUARD_CAMERAS, + START_LIVE_STREAMING_RVM, + encode_live_streaming, +) PARALLAX_RVMS: list[str] = list(RVMDecoder.decoders) @@ -85,14 +92,17 @@ "CHARGING_RVMS", "CLIMATE_HOLD_SETTING_RVM", "CLIMATE_HOLD_STATUS_RVM", + "GEAR_GUARD_CAMERAS", "LOCK_RVM", "PARALLAX_RVMS", + "START_LIVE_STREAMING_RVM", "UNLOCK_RVM", "decode_command_state_message", "decode_parallax_message", "decode_parallax_subscription_message", "encode_climate_hold_setting", "encode_command", + "encode_live_streaming", "encode_lock", "encode_unlock", "encode_vehicle_operation", diff --git a/src/rivian/parallax/proto/security.proto b/src/rivian/parallax/proto/security.proto index cebfd21..0457397 100644 --- a/src/rivian/parallax/proto/security.proto +++ b/src/rivian/parallax/proto/security.proto @@ -129,3 +129,19 @@ enum TosAcceptance { TOS_NOT_ACCEPTED = 1; TOS_ACCEPTED = 2; } + +// security.video_monitoring.start_live_streaming +message LiveStreamingRequest { + GearGuardCamera camera = 1; + string device_id = 2; // a label for the viewer; any stable string works +} + +enum GearGuardCamera { // the app's camera selector + GEAR_GUARD_CAMERA_UNSPECIFIED = 0; + GEAR_GUARD_CAMERA_FRONT = 1; + GEAR_GUARD_CAMERA_BED = 2; + GEAR_GUARD_CAMERA_LEFT = 3; + GEAR_GUARD_CAMERA_RIGHT = 4; + GEAR_GUARD_CAMERA_REAR = 5; + GEAR_GUARD_CAMERA_INTERIOR = 6; +} diff --git a/src/rivian/parallax/proto/security_pb2.py b/src/rivian/parallax/proto/security_pb2.py index 617a4b9..408b1e3 100644 --- a/src/rivian/parallax/proto/security_pb2.py +++ b/src/rivian/parallax/proto/security_pb2.py @@ -24,33 +24,35 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n$rivian/parallax/proto/security.proto\x12\x18rivian.parallax.security\"\x0e\n\x0cPassiveEntry\"\xe7\x03\n\x03\x42tm\x12:\n\x02\x66\x66\x18\x01 \x01(\x0e\x32).rivian.parallax.security.HardwareFailureH\x00\x88\x01\x01\x12:\n\x02ic\x18\x02 \x01(\x0e\x32).rivian.parallax.security.HardwareFailureH\x01\x88\x01\x01\x12;\n\x03lfd\x18\x03 \x01(\x0e\x32).rivian.parallax.security.HardwareFailureH\x02\x88\x01\x01\x12:\n\x02rf\x18\x04 \x01(\x0e\x32).rivian.parallax.security.HardwareFailureH\x03\x88\x01\x01\x12;\n\x03rfd\x18\x05 \x01(\x0e\x32).rivian.parallax.security.HardwareFailureH\x04\x88\x01\x01\x12:\n\x02oc\x18\x06 \x01(\x0e\x32).rivian.parallax.security.HardwareFailureH\x05\x88\x01\x01\x12\x11\n\tffr_error\x18\x07 \x01(\x05\x12\x11\n\trfr_error\x18\x08 \x01(\x05\x12\x11\n\tffl_error\x18\t \x01(\x05\x12\x11\n\trfl_error\x18\n \x01(\x05\x42\x05\n\x03_ffB\x05\n\x03_icB\x06\n\x04_lfdB\x05\n\x03_rfB\x06\n\x04_rfdB\x05\n\x03_oc\"_\n\x10ImmobilizerState\x12@\n\x06status\x18\x01 \x01(\x0e\x32+.rivian.parallax.security.ImmobilizerStatusH\x00\x88\x01\x01\x42\t\n\x07_status\"\xba\x01\n\x11PassiveEntryDebug\x12\x45\n\x06reason\x18\x01 \x01(\x0e\x32\x30.rivian.parallax.security.PassiveEntryFailReasonH\x00\x88\x01\x01\x12S\n\x1bsend_lock_fail_notification\x18\x02 \x01(\x0e\x32..rivian.parallax.security.LockFailNotificationB\t\n\x07_reason\"\xbe\x01\n\x08VasFault\x12K\n\x0esecure_element\x18\x01 \x01(\x0e\x32..rivian.parallax.security.SecureElementFaultedH\x00\x88\x01\x01\x12\x43\n\naccess_can\x18\x02 \x01(\x0e\x32*.rivian.parallax.security.AccessCanFaultedH\x01\x88\x01\x01\x42\x11\n\x0f_secure_elementB\r\n\x0b_access_can\"\x8d\x01\n\nAlarmState\x12>\n\x0bsound_alarm\x18\x01 \x01(\x0e\x32$.rivian.parallax.security.AlarmSoundH\x00\x88\x01\x01\x12/\n\'consecutive_alarm_disabled_notification\x18\x02 \x01(\x08\x42\x0e\n\x0c_sound_alarm\"\x81\x02\n\x14VideoMonitoringState\x12\x44\n\x06status\x18\x01 \x01(\x0e\x32/.rivian.parallax.security.VideoMonitoringStatusH\x00\x88\x01\x01\x12\x36\n\x04mode\x18\x02 \x01(\x0e\x32#.rivian.parallax.security.VideoModeH\x01\x88\x01\x01\x12\x44\n\x0eterms_accepted\x18\x03 \x01(\x0e\x32\'.rivian.parallax.security.TosAcceptanceH\x02\x88\x01\x01\x42\t\n\x07_statusB\x07\n\x05_modeB\x11\n\x0f_terms_accepted*M\n\x0fHardwareFailure\x12 \n\x1cHARDWARE_FAILURE_UNSPECIFIED\x10\x00\x12\x18\n\x14HARDWARE_FAILURE_SET\x10\x01*v\n\x11ImmobilizerStatus\x12\x1c\n\x18IMMOBILIZER_NOT_ASSIGNED\x10\x00\x12\x1e\n\x1aIMMOBILIZER_NOT_AUTHORIZED\x10\x01\x12#\n\x1fIMMOBILIZER_AUTHORIZED_TO_DRIVE\x10\x02*y\n\x14LockFailNotification\x12\x1e\n\x1aLOCK_FAIL_NOTIFICATION_SNA\x10\x00\x12\x1f\n\x1bLOCK_FAIL_NOTIFICATION_TRUE\x10\x01\x12 \n\x1cLOCK_FAIL_NOTIFICATION_FALSE\x10\x02*\xfe\x03\n\x16PassiveEntryFailReason\x12\"\n\x1ePASSIVE_ENTRY_FAIL_UNSPECIFIED\x10\x00\x12\x1d\n\x19PASSIVE_ENTRY_NOT_IN_PARK\x10\x01\x12!\n\x1dPASSIVE_ENTRY_AT_HOME_DISABLE\x10\x02\x12#\n\x1fPASSIVE_ENTRY_PASSENGER_IN_SEAT\x10\x03\x12$\n PASSIVE_ENTRY_DEVICE_NOT_ENABLED\x10\x04\x12 \n\x1cPASSIVE_ENTRY_TRANSPORT_MODE\x10\x05\x12\x1f\n\x1bPASSIVE_ENTRY_CAR_WASH_MODE\x10\x06\x12\x1b\n\x17PASSIVE_ENTRY_CAMP_MODE\x10\x07\x12\x1c\n\x18PASSIVE_ENTRY_ACTIVE_OTA\x10\x08\x12$\n PASSIVE_ENTRY_SHOW_AND_TELL_MODE\x10\t\x12#\n\x1fPASSIVE_ENTRY_RCVD_RSSI_PENDING\x10\n\x12#\n\x1fPASSIVE_ENTRY_LOCK_ONLY_AT_HOME\x10\x0b\x12\"\n\x1ePASSIVE_ENTRY_CAR_COSTUME_MODE\x10\x0c\x12!\n\x1dPASSIVE_ENTRY_SLEPT_IMMEDIATE\x10\r*\x9b\x02\n\x14SecureElementFaulted\x12$\n SECURE_ELEMENT_FAULT_UNSPECIFIED\x10\x00\x12\x1d\n\x19SECURE_ELEMENT_NO_FAILURE\x10\x01\x12%\n!SECURE_ELEMENT_LOST_COMMUNICATION\x10\x02\x12(\n$SECURE_ELEMENT_APPLET_NOT_PROGRAMMED\x10\x03\x12!\n\x1dSECURE_ELEMENT_NOT_CONFIGURED\x10\x04\x12!\n\x1dSECURE_ELEMENT_ATTACK_COUNTER\x10\x05\x12\'\n#SECURE_ELEMENT_URSK_DECRYPT_FAILURE\x10\x06*g\n\x10\x41\x63\x63\x65ssCanFaulted\x12 \n\x1c\x41\x43\x43\x45SS_CAN_FAULT_UNSPECIFIED\x10\x00\x12\x19\n\x15\x41\x43\x43\x45SS_CAN_NO_FAILURE\x10\x01\x12\x16\n\x12\x41\x43\x43\x45SS_CAN_FAILURE\x10\x02*|\n\nAlarmSound\x12\x1b\n\x17\x41LARM_SOUND_UNSPECIFIED\x10\x00\x12\x15\n\x11\x41LARM_SOUND_FALSE\x10\x01\x12\x14\n\x10\x41LARM_SOUND_TRUE\x10\x02\x12$\n ALARM_SOUND_SIGNAL_NOT_AVAILABLE\x10\x03*\xb8\x01\n\x15VideoMonitoringStatus\x12\'\n#VIDEO_MONITORING_STATUS_UNSPECIFIED\x10\x00\x12\x1d\n\x19VIDEO_MONITORING_DISABLED\x10\x01\x12\x1c\n\x18VIDEO_MONITORING_ENABLED\x10\x02\x12\x1b\n\x17VIDEO_MONITORING_ACTIVE\x10\x03\x12\x1c\n\x18VIDEO_MONITORING_FAULTED\x10\x04*Z\n\tVideoMode\x12\x13\n\x0fVIDEO_MODE_NONE\x10\x00\x12\x19\n\x15VIDEO_MODE_EVERYWHERE\x10\x01\x12\x1d\n\x19VIDEO_MODE_AWAY_FROM_HOME\x10\x02*W\n\rTosAcceptance\x12\x1e\n\x1aTOS_ACCEPTANCE_UNSPECIFIED\x10\x00\x12\x14\n\x10TOS_NOT_ACCEPTED\x10\x01\x12\x10\n\x0cTOS_ACCEPTED\x10\x02\x62\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n$rivian/parallax/proto/security.proto\x12\x18rivian.parallax.security\"\x0e\n\x0cPassiveEntry\"\xe7\x03\n\x03\x42tm\x12:\n\x02\x66\x66\x18\x01 \x01(\x0e\x32).rivian.parallax.security.HardwareFailureH\x00\x88\x01\x01\x12:\n\x02ic\x18\x02 \x01(\x0e\x32).rivian.parallax.security.HardwareFailureH\x01\x88\x01\x01\x12;\n\x03lfd\x18\x03 \x01(\x0e\x32).rivian.parallax.security.HardwareFailureH\x02\x88\x01\x01\x12:\n\x02rf\x18\x04 \x01(\x0e\x32).rivian.parallax.security.HardwareFailureH\x03\x88\x01\x01\x12;\n\x03rfd\x18\x05 \x01(\x0e\x32).rivian.parallax.security.HardwareFailureH\x04\x88\x01\x01\x12:\n\x02oc\x18\x06 \x01(\x0e\x32).rivian.parallax.security.HardwareFailureH\x05\x88\x01\x01\x12\x11\n\tffr_error\x18\x07 \x01(\x05\x12\x11\n\trfr_error\x18\x08 \x01(\x05\x12\x11\n\tffl_error\x18\t \x01(\x05\x12\x11\n\trfl_error\x18\n \x01(\x05\x42\x05\n\x03_ffB\x05\n\x03_icB\x06\n\x04_lfdB\x05\n\x03_rfB\x06\n\x04_rfdB\x05\n\x03_oc\"_\n\x10ImmobilizerState\x12@\n\x06status\x18\x01 \x01(\x0e\x32+.rivian.parallax.security.ImmobilizerStatusH\x00\x88\x01\x01\x42\t\n\x07_status\"\xba\x01\n\x11PassiveEntryDebug\x12\x45\n\x06reason\x18\x01 \x01(\x0e\x32\x30.rivian.parallax.security.PassiveEntryFailReasonH\x00\x88\x01\x01\x12S\n\x1bsend_lock_fail_notification\x18\x02 \x01(\x0e\x32..rivian.parallax.security.LockFailNotificationB\t\n\x07_reason\"\xbe\x01\n\x08VasFault\x12K\n\x0esecure_element\x18\x01 \x01(\x0e\x32..rivian.parallax.security.SecureElementFaultedH\x00\x88\x01\x01\x12\x43\n\naccess_can\x18\x02 \x01(\x0e\x32*.rivian.parallax.security.AccessCanFaultedH\x01\x88\x01\x01\x42\x11\n\x0f_secure_elementB\r\n\x0b_access_can\"\x8d\x01\n\nAlarmState\x12>\n\x0bsound_alarm\x18\x01 \x01(\x0e\x32$.rivian.parallax.security.AlarmSoundH\x00\x88\x01\x01\x12/\n\'consecutive_alarm_disabled_notification\x18\x02 \x01(\x08\x42\x0e\n\x0c_sound_alarm\"\x81\x02\n\x14VideoMonitoringState\x12\x44\n\x06status\x18\x01 \x01(\x0e\x32/.rivian.parallax.security.VideoMonitoringStatusH\x00\x88\x01\x01\x12\x36\n\x04mode\x18\x02 \x01(\x0e\x32#.rivian.parallax.security.VideoModeH\x01\x88\x01\x01\x12\x44\n\x0eterms_accepted\x18\x03 \x01(\x0e\x32\'.rivian.parallax.security.TosAcceptanceH\x02\x88\x01\x01\x42\t\n\x07_statusB\x07\n\x05_modeB\x11\n\x0f_terms_accepted\"d\n\x14LiveStreamingRequest\x12\x39\n\x06\x63\x61mera\x18\x01 \x01(\x0e\x32).rivian.parallax.security.GearGuardCamera\x12\x11\n\tdevice_id\x18\x02 \x01(\t*M\n\x0fHardwareFailure\x12 \n\x1cHARDWARE_FAILURE_UNSPECIFIED\x10\x00\x12\x18\n\x14HARDWARE_FAILURE_SET\x10\x01*v\n\x11ImmobilizerStatus\x12\x1c\n\x18IMMOBILIZER_NOT_ASSIGNED\x10\x00\x12\x1e\n\x1aIMMOBILIZER_NOT_AUTHORIZED\x10\x01\x12#\n\x1fIMMOBILIZER_AUTHORIZED_TO_DRIVE\x10\x02*y\n\x14LockFailNotification\x12\x1e\n\x1aLOCK_FAIL_NOTIFICATION_SNA\x10\x00\x12\x1f\n\x1bLOCK_FAIL_NOTIFICATION_TRUE\x10\x01\x12 \n\x1cLOCK_FAIL_NOTIFICATION_FALSE\x10\x02*\xfe\x03\n\x16PassiveEntryFailReason\x12\"\n\x1ePASSIVE_ENTRY_FAIL_UNSPECIFIED\x10\x00\x12\x1d\n\x19PASSIVE_ENTRY_NOT_IN_PARK\x10\x01\x12!\n\x1dPASSIVE_ENTRY_AT_HOME_DISABLE\x10\x02\x12#\n\x1fPASSIVE_ENTRY_PASSENGER_IN_SEAT\x10\x03\x12$\n PASSIVE_ENTRY_DEVICE_NOT_ENABLED\x10\x04\x12 \n\x1cPASSIVE_ENTRY_TRANSPORT_MODE\x10\x05\x12\x1f\n\x1bPASSIVE_ENTRY_CAR_WASH_MODE\x10\x06\x12\x1b\n\x17PASSIVE_ENTRY_CAMP_MODE\x10\x07\x12\x1c\n\x18PASSIVE_ENTRY_ACTIVE_OTA\x10\x08\x12$\n PASSIVE_ENTRY_SHOW_AND_TELL_MODE\x10\t\x12#\n\x1fPASSIVE_ENTRY_RCVD_RSSI_PENDING\x10\n\x12#\n\x1fPASSIVE_ENTRY_LOCK_ONLY_AT_HOME\x10\x0b\x12\"\n\x1ePASSIVE_ENTRY_CAR_COSTUME_MODE\x10\x0c\x12!\n\x1dPASSIVE_ENTRY_SLEPT_IMMEDIATE\x10\r*\x9b\x02\n\x14SecureElementFaulted\x12$\n SECURE_ELEMENT_FAULT_UNSPECIFIED\x10\x00\x12\x1d\n\x19SECURE_ELEMENT_NO_FAILURE\x10\x01\x12%\n!SECURE_ELEMENT_LOST_COMMUNICATION\x10\x02\x12(\n$SECURE_ELEMENT_APPLET_NOT_PROGRAMMED\x10\x03\x12!\n\x1dSECURE_ELEMENT_NOT_CONFIGURED\x10\x04\x12!\n\x1dSECURE_ELEMENT_ATTACK_COUNTER\x10\x05\x12\'\n#SECURE_ELEMENT_URSK_DECRYPT_FAILURE\x10\x06*g\n\x10\x41\x63\x63\x65ssCanFaulted\x12 \n\x1c\x41\x43\x43\x45SS_CAN_FAULT_UNSPECIFIED\x10\x00\x12\x19\n\x15\x41\x43\x43\x45SS_CAN_NO_FAILURE\x10\x01\x12\x16\n\x12\x41\x43\x43\x45SS_CAN_FAILURE\x10\x02*|\n\nAlarmSound\x12\x1b\n\x17\x41LARM_SOUND_UNSPECIFIED\x10\x00\x12\x15\n\x11\x41LARM_SOUND_FALSE\x10\x01\x12\x14\n\x10\x41LARM_SOUND_TRUE\x10\x02\x12$\n ALARM_SOUND_SIGNAL_NOT_AVAILABLE\x10\x03*\xb8\x01\n\x15VideoMonitoringStatus\x12\'\n#VIDEO_MONITORING_STATUS_UNSPECIFIED\x10\x00\x12\x1d\n\x19VIDEO_MONITORING_DISABLED\x10\x01\x12\x1c\n\x18VIDEO_MONITORING_ENABLED\x10\x02\x12\x1b\n\x17VIDEO_MONITORING_ACTIVE\x10\x03\x12\x1c\n\x18VIDEO_MONITORING_FAULTED\x10\x04*Z\n\tVideoMode\x12\x13\n\x0fVIDEO_MODE_NONE\x10\x00\x12\x19\n\x15VIDEO_MODE_EVERYWHERE\x10\x01\x12\x1d\n\x19VIDEO_MODE_AWAY_FROM_HOME\x10\x02*W\n\rTosAcceptance\x12\x1e\n\x1aTOS_ACCEPTANCE_UNSPECIFIED\x10\x00\x12\x14\n\x10TOS_NOT_ACCEPTED\x10\x01\x12\x10\n\x0cTOS_ACCEPTED\x10\x02*\xe1\x01\n\x0fGearGuardCamera\x12!\n\x1dGEAR_GUARD_CAMERA_UNSPECIFIED\x10\x00\x12\x1b\n\x17GEAR_GUARD_CAMERA_FRONT\x10\x01\x12\x19\n\x15GEAR_GUARD_CAMERA_BED\x10\x02\x12\x1a\n\x16GEAR_GUARD_CAMERA_LEFT\x10\x03\x12\x1b\n\x17GEAR_GUARD_CAMERA_RIGHT\x10\x04\x12\x1a\n\x16GEAR_GUARD_CAMERA_REAR\x10\x05\x12\x1e\n\x1aGEAR_GUARD_CAMERA_INTERIOR\x10\x06\x62\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'rivian.parallax.proto.security_pb2', _globals) if not _descriptor._USE_C_DESCRIPTORS: DESCRIPTOR._loaded_options = None - _globals['_HARDWAREFAILURE']._serialized_start=1455 - _globals['_HARDWAREFAILURE']._serialized_end=1532 - _globals['_IMMOBILIZERSTATUS']._serialized_start=1534 - _globals['_IMMOBILIZERSTATUS']._serialized_end=1652 - _globals['_LOCKFAILNOTIFICATION']._serialized_start=1654 - _globals['_LOCKFAILNOTIFICATION']._serialized_end=1775 - _globals['_PASSIVEENTRYFAILREASON']._serialized_start=1778 - _globals['_PASSIVEENTRYFAILREASON']._serialized_end=2288 - _globals['_SECUREELEMENTFAULTED']._serialized_start=2291 - _globals['_SECUREELEMENTFAULTED']._serialized_end=2574 - _globals['_ACCESSCANFAULTED']._serialized_start=2576 - _globals['_ACCESSCANFAULTED']._serialized_end=2679 - _globals['_ALARMSOUND']._serialized_start=2681 - _globals['_ALARMSOUND']._serialized_end=2805 - _globals['_VIDEOMONITORINGSTATUS']._serialized_start=2808 - _globals['_VIDEOMONITORINGSTATUS']._serialized_end=2992 - _globals['_VIDEOMODE']._serialized_start=2994 - _globals['_VIDEOMODE']._serialized_end=3084 - _globals['_TOSACCEPTANCE']._serialized_start=3086 - _globals['_TOSACCEPTANCE']._serialized_end=3173 + _globals['_HARDWAREFAILURE']._serialized_start=1557 + _globals['_HARDWAREFAILURE']._serialized_end=1634 + _globals['_IMMOBILIZERSTATUS']._serialized_start=1636 + _globals['_IMMOBILIZERSTATUS']._serialized_end=1754 + _globals['_LOCKFAILNOTIFICATION']._serialized_start=1756 + _globals['_LOCKFAILNOTIFICATION']._serialized_end=1877 + _globals['_PASSIVEENTRYFAILREASON']._serialized_start=1880 + _globals['_PASSIVEENTRYFAILREASON']._serialized_end=2390 + _globals['_SECUREELEMENTFAULTED']._serialized_start=2393 + _globals['_SECUREELEMENTFAULTED']._serialized_end=2676 + _globals['_ACCESSCANFAULTED']._serialized_start=2678 + _globals['_ACCESSCANFAULTED']._serialized_end=2781 + _globals['_ALARMSOUND']._serialized_start=2783 + _globals['_ALARMSOUND']._serialized_end=2907 + _globals['_VIDEOMONITORINGSTATUS']._serialized_start=2910 + _globals['_VIDEOMONITORINGSTATUS']._serialized_end=3094 + _globals['_VIDEOMODE']._serialized_start=3096 + _globals['_VIDEOMODE']._serialized_end=3186 + _globals['_TOSACCEPTANCE']._serialized_start=3188 + _globals['_TOSACCEPTANCE']._serialized_end=3275 + _globals['_GEARGUARDCAMERA']._serialized_start=3278 + _globals['_GEARGUARDCAMERA']._serialized_end=3503 _globals['_PASSIVEENTRY']._serialized_start=66 _globals['_PASSIVEENTRY']._serialized_end=80 _globals['_BTM']._serialized_start=83 @@ -65,4 +67,6 @@ _globals['_ALARMSTATE']._serialized_end=1193 _globals['_VIDEOMONITORINGSTATE']._serialized_start=1196 _globals['_VIDEOMONITORINGSTATE']._serialized_end=1453 + _globals['_LIVESTREAMINGREQUEST']._serialized_start=1455 + _globals['_LIVESTREAMINGREQUEST']._serialized_end=1555 # @@protoc_insertion_point(module_scope) diff --git a/src/rivian/parallax/proto/security_pb2.pyi b/src/rivian/parallax/proto/security_pb2.pyi index 58cc421..e04e8c7 100644 --- a/src/rivian/parallax/proto/security_pb2.pyi +++ b/src/rivian/parallax/proto/security_pb2.pyi @@ -81,6 +81,16 @@ class TosAcceptance(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): TOS_ACCEPTANCE_UNSPECIFIED: _ClassVar[TosAcceptance] TOS_NOT_ACCEPTED: _ClassVar[TosAcceptance] TOS_ACCEPTED: _ClassVar[TosAcceptance] + +class GearGuardCamera(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + GEAR_GUARD_CAMERA_UNSPECIFIED: _ClassVar[GearGuardCamera] + GEAR_GUARD_CAMERA_FRONT: _ClassVar[GearGuardCamera] + GEAR_GUARD_CAMERA_BED: _ClassVar[GearGuardCamera] + GEAR_GUARD_CAMERA_LEFT: _ClassVar[GearGuardCamera] + GEAR_GUARD_CAMERA_RIGHT: _ClassVar[GearGuardCamera] + GEAR_GUARD_CAMERA_REAR: _ClassVar[GearGuardCamera] + GEAR_GUARD_CAMERA_INTERIOR: _ClassVar[GearGuardCamera] HARDWARE_FAILURE_UNSPECIFIED: HardwareFailure HARDWARE_FAILURE_SET: HardwareFailure IMMOBILIZER_NOT_ASSIGNED: ImmobilizerStatus @@ -128,6 +138,13 @@ VIDEO_MODE_AWAY_FROM_HOME: VideoMode TOS_ACCEPTANCE_UNSPECIFIED: TosAcceptance TOS_NOT_ACCEPTED: TosAcceptance TOS_ACCEPTED: TosAcceptance +GEAR_GUARD_CAMERA_UNSPECIFIED: GearGuardCamera +GEAR_GUARD_CAMERA_FRONT: GearGuardCamera +GEAR_GUARD_CAMERA_BED: GearGuardCamera +GEAR_GUARD_CAMERA_LEFT: GearGuardCamera +GEAR_GUARD_CAMERA_RIGHT: GearGuardCamera +GEAR_GUARD_CAMERA_REAR: GearGuardCamera +GEAR_GUARD_CAMERA_INTERIOR: GearGuardCamera class PassiveEntry(_message.Message): __slots__ = () @@ -196,3 +213,11 @@ class VideoMonitoringState(_message.Message): mode: VideoMode terms_accepted: TosAcceptance def __init__(self, status: _Optional[_Union[VideoMonitoringStatus, str]] = ..., mode: _Optional[_Union[VideoMode, str]] = ..., terms_accepted: _Optional[_Union[TosAcceptance, str]] = ...) -> None: ... + +class LiveStreamingRequest(_message.Message): + __slots__ = ("camera", "device_id") + CAMERA_FIELD_NUMBER: _ClassVar[int] + DEVICE_ID_FIELD_NUMBER: _ClassVar[int] + camera: GearGuardCamera + device_id: str + def __init__(self, camera: _Optional[_Union[GearGuardCamera, str]] = ..., device_id: _Optional[str] = ...) -> None: ... diff --git a/src/rivian/parallax/security.py b/src/rivian/parallax/security.py index bce9291..c0f40fb 100644 --- a/src/rivian/parallax/security.py +++ b/src/rivian/parallax/security.py @@ -7,6 +7,24 @@ from .core import RVMDecoder, _enum, _present from .proto import security_pb2 +START_LIVE_STREAMING_RVM: Final = "security.video_monitoring.start_live_streaming" + +GEAR_GUARD_CAMERAS: Final[dict[str, security_pb2.GearGuardCamera]] = { + "front": security_pb2.GEAR_GUARD_CAMERA_FRONT, + "bed": security_pb2.GEAR_GUARD_CAMERA_BED, + "left": security_pb2.GEAR_GUARD_CAMERA_LEFT, + "right": security_pb2.GEAR_GUARD_CAMERA_RIGHT, + "rear": security_pb2.GEAR_GUARD_CAMERA_REAR, + "interior": security_pb2.GEAR_GUARD_CAMERA_INTERIOR, +} + + +def encode_live_streaming(camera: str, device_id: str) -> bytes: + """Encode a security.video_monitoring.start_live_streaming payload.""" + return security_pb2.LiveStreamingRequest( + camera=GEAR_GUARD_CAMERAS[camera], device_id=device_id + ).SerializeToString() + @RVMDecoder.register("security.access.passive_entry", security_pb2.PassiveEntry) def decode_passive_entry(_m: security_pb2.PassiveEntry) -> dict[str, Any]: diff --git a/src/rivian/rivian.py b/src/rivian/rivian.py index ea2878f..17f217a 100644 --- a/src/rivian/rivian.py +++ b/src/rivian/rivian.py @@ -34,8 +34,11 @@ from .parallax import ( CLIMATE_HOLD_SETTING_RVM, CLIMATE_HOLD_STATUS_RVM, + GEAR_GUARD_CAMERAS, + START_LIVE_STREAMING_RVM, encode_climate_hold_setting, encode_command, + encode_live_streaming, encode_vehicle_operation, ) from .utils import generate_vehicle_command_hmac, send_deprecation_warning @@ -665,6 +668,81 @@ async def send_location_to_vehicle( } return await self.__graphql_query(headers, url, graphql_json) + async def start_gear_guard_live_stream( + self, + vehicle_id: str, + camera: str = "front", + *, + identity_id: str, + vehicle_key: str, + private_key: str, + device_id: str = "rivian-python-client", + timeout: float = 40, + ) -> dict[str, Any] | None: + """Start a Gear Guard live stream and return its Kinesis WebRTC config. + + `camera` is a `parallax.GEAR_GUARD_CAMERAS` key. Needs Gear Guard video + monitoring on and a paired phone; the vehicle serves one viewer at a time, + about 3 minutes each and an hour a day. None if the start fails or no config + arrives within `timeout` seconds (a sleeping vehicle may need longer). + Raises RivianApiException if the vehicle refuses the stream, e.g. for a + camera it doesn't have. + """ + if camera not in GEAR_GUARD_CAMERAS: + raise RivianBadRequestError(f"Unknown camera: {camera}") + command_id = str(uuid.uuid4()) + config: asyncio.Future[dict[str, Any]] = ( + asyncio.get_running_loop().create_future() + ) + + def on_message(message: dict[str, Any]) -> None: + data = (message.get("payload") or {}).get("data") or {} + live = data.get("gearGuardLiveConfig") or {} + if live.get("endpoint") and not config.done(): + config.set_result(live) + + def on_error(message: dict[str, Any]) -> None: + errors = message.get("payload") or [{}] + reason = (errors[0].get("extensions") or {}).get("reason") + if not config.done(): + config.set_exception( + RivianApiException(f"The vehicle refused the stream: {reason}") + ) + + # The cloud sends the config to the subscription whose commandId is the + # start command's id, as hex + unsubscribe = await self._start_subscription( + { + "operationName": "GearGuardLiveConfigSubscription", + "query": "subscription GearGuardLiveConfigSubscription($vehicleId: String!, $commandId: String!) { gearGuardLiveConfig(vehicleId: $vehicleId, commandId: $commandId) { __typename endpoint channelArn role iceServers { __typename url username credential ttl } } }", + "variables": { + "vehicleId": vehicle_id, + "commandId": uuid.UUID(command_id).hex, + }, + }, + on_message, + f"{vehicle_id} Gear Guard live config", + on_error, + ) + if not unsubscribe: + return None + try: + if not await self.send_parallax_command( + vehicle_id, + START_LIVE_STREAMING_RVM, + encode_live_streaming(camera, device_id), + identity_id=identity_id, + vehicle_key=vehicle_key, + private_key=private_key, + command_id=command_id, + ): + return None + return await asyncio.wait_for(config, timeout) + except TimeoutError: + return None + finally: + await unsubscribe() + async def get_vehicle_ota_update_details(self, vehicle_id: str) -> ClientResponse: """Get vehicle OTA update details.""" url = GRAPHQL_GATEWAY diff --git a/tests/parallax/test_security.py b/tests/parallax/test_security.py index 1864196..7c5189d 100644 --- a/tests/parallax/test_security.py +++ b/tests/parallax/test_security.py @@ -2,6 +2,7 @@ from __future__ import annotations +from rivian.parallax import encode_live_streaming from rivian.parallax.proto import security_pb2 as security from .helpers import decode @@ -35,3 +36,12 @@ def test_alarm_state() -> None: "alarmSoundStatus": "false", "consecutiveAlarmDisabledNotification": True, } + + +def test_encode_live_streaming() -> None: + """A live stream start names the camera and the viewer's device id.""" + request = security.LiveStreamingRequest.FromString( + encode_live_streaming("rear", "viewer") + ) + assert request.camera == security.GEAR_GUARD_CAMERA_REAR + assert request.device_id == "viewer" diff --git a/tests/test_rivian.py b/tests/test_rivian.py index 634571c..e48e0bd 100644 --- a/tests/test_rivian.py +++ b/tests/test_rivian.py @@ -447,6 +447,65 @@ async def test_send_location_to_vehicle(aresponses: ResponsesMockServer) -> None await rivian.close() +async def test_start_gear_guard_live_stream(aresponses: ResponsesMockServer) -> None: + """Test starting a Gear Guard live stream.""" + requests = capture_requests( + aresponses, + SEND_PARALLAX_COMMAND_RESPONSE, + {"data": {"sendParallaxPayload": None}}, + SEND_PARALLAX_COMMAND_RESPONSE, + ) + vehicle_key, private_key = generate_key_pair() + keys = { + "identity_id": "identity_id", + "vehicle_key": vehicle_key, + "private_key": private_key, + } + live = {"endpoint": "wss://kinesis", "role": "VIEWER", "iceServers": []} + async with aiohttp.ClientSession(): + rivian = Rivian( + csrf_token="csrf", + app_session_token="token", + user_session_token="token", + ) + subscriptions: list[dict[str, Any]] = [] + unsubscribe = AsyncMock() + + async def subscribe( + payload: dict[str, Any], callback: Any, _: str, on_error: Any + ) -> Any: + subscriptions.append(payload) + callback({"payload": {"data": {"gearGuardLiveConfig": live}}}) + return unsubscribe + + rivian._start_subscription = subscribe # type: ignore[method-assign] + config = await rivian.start_gear_guard_live_stream("vehicle_id", "rear", **keys) + assert config == live + _, operation = decode_parallax_command(requests[0]) + assert operation.rvm == "security.video_monitoring.start_live_streaming" + # The config is routed by the start command's id + assert subscriptions[0]["variables"]["commandId"] == operation.command_id.hex() + + # A rejected start returns None and still ends the subscription + assert not await rivian.start_gear_guard_live_stream("vehicle_id", **keys) + assert unsubscribe.await_count == 2 + with pytest.raises(RivianBadRequestError): + await rivian.start_gear_guard_live_stream("vehicle_id", "roof", **keys) + + # A refused start fails straight away instead of timing out + async def refuse( + payload: dict[str, Any], callback: Any, _: str, on_error: Any + ) -> Any: + on_error( + {"payload": [{"extensions": {"reason": "VEHICLE_COMMAND_FORBIDDEN"}}]} + ) + return unsubscribe + + rivian._start_subscription = refuse # type: ignore[method-assign] + with pytest.raises(RivianApiException, match="VEHICLE_COMMAND_FORBIDDEN"): + await rivian.start_gear_guard_live_stream("vehicle_id", timeout=30, **keys) + + async def test_subscribe_for_departure_schedules() -> None: """Test subscribing to departure schedules.""" rivian = Rivian(user_session_token="token")