From 09d98d1e664ea41ef5d9419bd4945bd12e6355c8 Mon Sep 17 00:00:00 2001 From: Walt Howd Date: Sun, 8 Nov 2026 03:43:34 +0000 Subject: [PATCH] Add sending and ending trips - `send_trip_to_vehicle` sends stops the way the Rivian app's trip planner does. The vehicle plans the route and charging stops itself, arriving with at least the given charge, at chargers of the given networks (`CHARGING_NETWORKS`, named like the app's charging filter; all by default, as the app sends without a filter). - `end_trip` ends the trip the vehicle is navigating, with an unsigned `sendParallaxPayload` on `c2v_navigation.trip_copilot.updated_trip`. `parallax.encode_end_trip` builds it from a new `UpdatedTrip` schema, in the same envelope as a signed command. Neither needs a paired phone key. Verified against a 2026 R1S (Gen 2) running 2026.36.01: a one-stop trip appeared in the vehicle's navigation, and `end_trip` ended it. Not tested: several stops, a network filter, Gen 1 vehicles. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FxWHBTrHU6eTPotwMWgtJF --- src/rivian/parallax/__init__.py | 5 + src/rivian/parallax/navigation.py | 30 ++++- src/rivian/parallax/proto/navigation.proto | 17 +++ src/rivian/parallax/proto/navigation_pb2.py | 14 ++- src/rivian/parallax/proto/navigation_pb2.pyi | 23 ++++ src/rivian/rivian.py | 122 +++++++++++++++++++ tests/parallax/test_navigation.py | 29 ++++- tests/test_rivian.py | 54 ++++++++ 8 files changed, 289 insertions(+), 5 deletions(-) diff --git a/src/rivian/parallax/__init__.py b/src/rivian/parallax/__init__.py index c5ebbd4..530e1a5 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_end_trip(user_id) -> str + Builds the unsigned `end_trip` request. Layout: core.py - registry, payload parsing and shared helpers @@ -64,6 +66,7 @@ decode_parallax_message, decode_parallax_subscription_message, ) +from .navigation import UPDATED_TRIP_MODEL, encode_end_trip from .operation import encode_vehicle_operation PARALLAX_RVMS: list[str] = list(RVMDecoder.decoders) @@ -88,11 +91,13 @@ "LOCK_RVM", "PARALLAX_RVMS", "UNLOCK_RVM", + "UPDATED_TRIP_MODEL", "decode_command_state_message", "decode_parallax_message", "decode_parallax_subscription_message", "encode_climate_hold_setting", "encode_command", + "encode_end_trip", "encode_lock", "encode_unlock", "encode_vehicle_operation", diff --git a/src/rivian/parallax/navigation.py b/src/rivian/parallax/navigation.py index 50a1cce..4bd07fe 100644 --- a/src/rivian/parallax/navigation.py +++ b/src/rivian/parallax/navigation.py @@ -2,6 +2,9 @@ from __future__ import annotations +import base64 +import time +import uuid from datetime import datetime from typing import Any, Final @@ -9,7 +12,32 @@ from ..utils import from_epoch from .core import RVMDecoder, _enum, _present -from .proto import navigation_pb2 +from .proto import command_pb2, navigation_pb2 + +UPDATED_TRIP_MODEL: Final = "c2v_navigation.trip_copilot.updated_trip" + + +def encode_end_trip( + user_id: str, + *, + command_id: str | None = None, + timestamp: float | None = None, +) -> str: + """Encode an unsigned `sendParallaxPayload` that ends the vehicle's trip.""" + now = time.time() if timestamp is None else timestamp + trip = navigation_pb2.UpdatedTrip( + route=navigation_pb2.UpdatedTrip.Route(route_id=f"end_trip_{int(now * 1000)}"), + user=navigation_pb2.UpdatedTrip.User(user_id=user_id), + ) + trip.settings.SetInParent() # present but empty, as the app sends it + envelope = command_pb2.Envelope( + composite=command_pb2.Composite(bytes_val=trip.SerializeToString()), + last_update=command_pb2.Timestamp(seconds=int(now), nanos=int(now % 1 * 1e9)), + correlation_id=uuid.UUID(command_id).bytes + if command_id + else uuid.uuid4().bytes, + ) + return base64.b64encode(envelope.SerializeToString()).decode("utf-8") def _parse_route_switches(raw: list[str]) -> dict[str, bool]: diff --git a/src/rivian/parallax/proto/navigation.proto b/src/rivian/parallax/proto/navigation.proto index eb5191f..7df1a6e 100644 --- a/src/rivian/parallax/proto/navigation.proto +++ b/src/rivian/parallax/proto/navigation.proto @@ -199,3 +199,20 @@ message TripProgress { double duration_remaining = 5; // seconds; this leg GpsFix location_fix = 6; } + +// c2v_navigation.trip_copilot.updated_trip, sent unsigned with +// sendParallaxPayload in a command.proto Envelope. Only the fields that end a +// trip are known here. +message UpdatedTrip { + Route route = 1; + Settings settings = 2; // sent empty to end a trip + User user = 3; + + message Route { + string route_id = 1; // "end_trip_" ends the trip + } + message Settings {} + message User { + string user_id = 1; + } +} diff --git a/src/rivian/parallax/proto/navigation_pb2.py b/src/rivian/parallax/proto/navigation_pb2.py index abb11c5..c9cf786 100644 --- a/src/rivian/parallax/proto/navigation_pb2.py +++ b/src/rivian/parallax/proto/navigation_pb2.py @@ -24,15 +24,15 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n&rivian/parallax/proto/navigation.proto\x12\x1arivian.parallax.navigation\"Y\n\rGeoCoordinate\x12\x15\n\x08latitude\x18\x01 \x01(\x01H\x00\x88\x01\x01\x12\x16\n\tlongitude\x18\x02 \x01(\x01H\x01\x88\x01\x01\x42\x0b\n\t_latitudeB\x0c\n\n_longitude\"K\n\tTimestamp\x12\x14\n\x07seconds\x18\x01 \x01(\x03H\x00\x88\x01\x01\x12\x12\n\x05nanos\x18\x02 \x01(\x05H\x01\x88\x01\x01\x42\n\n\x08_secondsB\x08\n\x06_nanos\"\xb6\x01\n\x06GpsFix\x12;\n\x08location\x18\x01 \x01(\x0b\x32).rivian.parallax.navigation.GeoCoordinate\x12\r\n\x05speed\x18\x02 \x01(\x02\x12\x0f\n\x07\x62\x65\x61ring\x18\x03 \x01(\x02\x12\x0f\n\x07offroad\x18\x04 \x01(\x08\x12\x11\n\x04time\x18\x05 \x01(\x03H\x00\x88\x01\x01\x12\x10\n\x08\x61ltitude\x18\x06 \x01(\x01\x12\x10\n\x08\x61\x63\x63uracy\x18\x07 \x01(\x01\x42\x07\n\x05_time\"\xd2!\n\x08TripInfo\x12\x14\n\x07trip_id\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x32\n\x06origin\x18\x02 \x01(\x0b\x32\".rivian.parallax.navigation.GpsFix\x12\x37\n\x04trip\x18\x03 \x01(\x0b\x32).rivian.parallax.navigation.TripInfo.Trip\x12P\n\x11route_preferences\x18\x04 \x01(\x0b\x32\x35.rivian.parallax.navigation.TripInfo.RoutePreferences\x12\x17\n\norigin_soc\x18\x05 \x01(\x01H\x01\x88\x01\x01\x12\x46\n\x17next_waypoint_departure\x18\x06 \x01(\x0b\x32%.rivian.parallax.navigation.Timestamp\x12\x46\n\x0c\x66\x61ster_route\x18\x07 \x01(\x0b\x32\x30.rivian.parallax.navigation.TripInfo.FasterRoute\x1a\xa8\x05\n\x04Trip\x12\x15\n\x08\x64istance\x18\x01 \x01(\x01H\x00\x88\x01\x01\x12\x15\n\x08\x64uration\x18\x02 \x01(\x01H\x01\x88\x01\x01\x12\x46\n\x08waypoint\x18\x03 \x03(\x0b\x32\x34.rivian.parallax.navigation.TripInfo.WaypointWrapper\x12\x35\n\x03leg\x18\x04 \x03(\x0b\x32(.rivian.parallax.navigation.TripInfo.Leg\x12\x1a\n\x12soc_is_below_limit\x18\x05 \x01(\x08\x12\x16\n\tfinal_soc\x18\x06 \x01(\x01H\x02\x88\x01\x01\x12\x18\n\x0b\x66inal_range\x18\x07 \x01(\x01H\x03\x88\x01\x01\x12I\n\x16\x62\x61ttery_empty_location\x18\x08 \x01(\x0b\x32).rivian.parallax.navigation.GeoCoordinate\x12-\n%battery_empty_to_destination_distance\x18\t \x01(\x01\x12\x1e\n\x11overview_polyline\x18\n \x01(\tH\x04\x88\x01\x01\x12Q\n\x12\x61lternative_routes\x18\x0b \x03(\x0b\x32\x35.rivian.parallax.navigation.TripInfo.AlternativeRoute\x12\x13\n\x0broute_index\x18\x0c \x01(\x05\x12U\n\x14\x61lternative_overlaps\x18\r \x03(\x0b\x32\x37.rivian.parallax.navigation.TripInfo.AlternativeOverlapB\x0b\n\t_distanceB\x0b\n\t_durationB\x0c\n\n_final_socB\x0e\n\x0c_final_rangeB\x14\n\x12_overview_polyline\x1a\x96\x01\n\x0fWaypointWrapper\x12\x39\n\x05place\x18\x01 \x01(\x0b\x32*.rivian.parallax.navigation.TripInfo.Place\x12H\n\rcharging_stop\x18\x02 \x01(\x0b\x32\x31.rivian.parallax.navigation.TripInfo.ChargingStop\x1a\xec\x02\n\x05Place\x12;\n\x08location\x18\x01 \x01(\x0b\x32).rivian.parallax.navigation.GeoCoordinate\x12\x43\n\x10snapped_location\x18\x02 \x01(\x0b\x32).rivian.parallax.navigation.GeoCoordinate\x12\x36\n\x06status\x18\x03 \x01(\x0e\x32&.rivian.parallax.navigation.StopStatus\x12\x11\n\x04name\x18\x04 \x01(\tH\x00\x88\x01\x01\x12\x15\n\x08place_id\x18\x05 \x01(\tH\x01\x88\x01\x01\x12\x15\n\rstop_duration\x18\x06 \x01(\x01\x12\x0b\n\x03soc\x18\x07 \x01(\x01\x12\r\n\x05range\x18\x08 \x01(\x01\x12\x36\n\x07\x61rrival\x18\t \x01(\x0b\x32%.rivian.parallax.navigation.TimestampB\x07\n\x05_nameB\x0b\n\t_place_id\x1a\xe4\x05\n\x0c\x43hargingStop\x12\x17\n\nstation_id\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x12\n\x05label\x18\x02 \x01(\tH\x01\x88\x01\x01\x12;\n\x08location\x18\x03 \x01(\x0b\x32).rivian.parallax.navigation.GeoCoordinate\x12\x1c\n\x0f\x63harge_duration\x18\x04 \x01(\x01H\x02\x88\x01\x01\x12U\n\x14\x63harge_data_accuracy\x18\x05 \x01(\x0e\x32\x37.rivian.parallax.navigation.TripInfo.ChargeDataAccuracy\x12\x18\n\x0b\x61rrival_soc\x18\x06 \x01(\x01H\x03\x88\x01\x01\x12\x1a\n\rarrival_range\x18\x07 \x01(\x01H\x04\x88\x01\x01\x12\x1a\n\rdeparture_soc\x18\x08 \x01(\x01H\x05\x88\x01\x01\x12\x1c\n\x0f\x64\x65parture_range\x18\t \x01(\x01H\x06\x88\x01\x01\x12=\n\x0e\x64\x65parture_time\x18\n \x01(\x0b\x32%.rivian.parallax.navigation.Timestamp\x12;\n\x0c\x61rrival_time\x18\x0b \x01(\x0b\x32%.rivian.parallax.navigation.Timestamp\x12\x17\n\x0fis_system_added\x18\x0c \x01(\x08\x12\x12\n\ncompatible\x18\r \x01(\x08\x12\x18\n\x10\x61\x64\x61pter_required\x18\x0e \x01(\x08\x12\x15\n\rstop_duration\x18\x10 \x01(\x01\x12\x36\n\x06status\x18\x11 \x01(\x0e\x32&.rivian.parallax.navigation.StopStatusB\r\n\x0b_station_idB\x08\n\x06_labelB\x12\n\x10_charge_durationB\x0e\n\x0c_arrival_socB\x10\n\x0e_arrival_rangeB\x10\n\x0e_departure_socB\x12\n\x10_departure_range\x1a\xdf\x07\n\x03Leg\x12\x10\n\x08\x64istance\x18\x01 \x01(\x01\x12\x10\n\x08\x64uration\x18\x02 \x01(\x01\x12\x17\n\nroad_label\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x15\n\x08polyline\x18\x04 \x01(\tH\x01\x88\x01\x01\x12\x14\n\x0ctraffic_slow\x18\x05 \x03(\x05\x12\x13\n\x0btraffic_jam\x18\x06 \x03(\x05\x12\x16\n\x0etraffic_severe\x18\x07 \x03(\x05\x12J\n\x06\x65nergy\x18\x08 \x01(\x0b\x32:.rivian.parallax.navigation.TripInfo.Leg.EnergyConsumption\x12\x44\n\tincidents\x18\t \x03(\x0b\x32\x31.rivian.parallax.navigation.TripInfo.Leg.Incident\x12M\n\x0etraffic_blocks\x18\n \x03(\x0b\x32\x35.rivian.parallax.navigation.TripInfo.Leg.TrafficBlock\x1a`\n\x11\x45nergyConsumption\x12\r\n\x05total\x18\x01 \x01(\x01\x12\x0f\n\x07thermal\x18\x02 \x01(\x01\x12\n\n\x02lv\x18\x03 \x01(\x01\x12\x0c\n\x04hvac\x18\x04 \x01(\x01\x12\x11\n\televation\x18\x05 \x01(\x01\x1a\x8a\x01\n\x08Incident\x12\x1c\n\x14polyline_first_index\x18\x01 \x01(\x05\x12\x1b\n\x13polyline_last_index\x18\x02 \x01(\x05\x12\x43\n\x04type\x18\x03 \x01(\x0e\x32\x35.rivian.parallax.navigation.TripInfo.Leg.IncidentType\x1a\x84\x01\n\x0cTrafficBlock\x12\x13\n\x0b\x66irst_index\x18\x01 \x01(\x05\x12\x1c\n\x14\x66irst_index_fraction\x18\x02 \x01(\x02\x12\x12\n\nlast_index\x18\x03 \x01(\x05\x12\x1b\n\x13last_index_fraction\x18\x04 \x01(\x02\x12\x10\n\x08priority\x18\x05 \x01(\x05\"\xcd\x01\n\x0cIncidentType\x12\x14\n\x10INCIDENT_UNKNOWN\x10\x00\x12\x11\n\rINCIDENT_NONE\x10\x01\x12\x14\n\x10INCIDENT_CLOSURE\x10\x02\x12\x12\n\x0eINCIDENT_CRASH\x10\x03\x12\x19\n\x15INCIDENT_CONSTRUCTION\x10\x04\x12\x10\n\x0cINCIDENT_JAM\x10\x05\x12\x1c\n\x18INCIDENT_POLICE_PRESENCE\x10\x06\x12\x1f\n\x1bINCIDENT_FIXED_SPEED_CAMERA\x10\x07\x42\r\n\x0b_road_labelB\x0b\n\t_polyline\x1a\xc7\x01\n\x10\x41lternativeRoute\x12\x35\n\x03leg\x18\x01 \x03(\x0b\x32(.rivian.parallax.navigation.TripInfo.Leg\x12\x10\n\x08polyline\x18\x02 \x01(\t\x12\x13\n\x0broute_index\x18\x03 \x01(\x05\x12U\n\x14\x61lternative_overlaps\x18\x04 \x03(\x0b\x32\x37.rivian.parallax.navigation.TripInfo.AlternativeOverlap\x1a\x93\x02\n\x12\x41lternativeOverlap\x12\x19\n\x11\x61lternative_index\x18\x01 \x01(\x05\x12\x61\n\x18not_overlapping_segments\x18\x02 \x03(\x0b\x32?.rivian.parallax.navigation.TripInfo.AlternativeOverlap.Segment\x1a\x7f\n\x07Segment\x12\x1d\n\x15start_drive_leg_index\x18\x01 \x01(\x05\x12\x1c\n\x14start_polyline_index\x18\x02 \x01(\x05\x12\x1b\n\x13\x65nd_drive_leg_index\x18\x03 \x01(\x05\x12\x1a\n\x12\x65nd_polyline_index\x18\x04 \x01(\x05\x1a\xc0\x01\n\x10RoutePreferences\x12 \n\x13\x63urrent_arrival_soc\x18\x01 \x01(\x02H\x00\x88\x01\x01\x12 \n\x13\x64\x65\x66\x61ult_arrival_soc\x18\x02 \x01(\x02H\x01\x88\x01\x01\x12\x16\n\x0eroad_avoidance\x18\x03 \x03(\t\x12 \n\x18\x63harging_network_filters\x18\x04 \x03(\tB\x16\n\x14_current_arrival_socB\x16\n\x14_default_arrival_soc\x1a[\n\x0b\x46\x61sterRoute\x12\x38\n\x05route\x18\x01 \x01(\x0b\x32).rivian.parallax.navigation.TripInfo.Trip\x12\x12\n\ntime_saved\x18\x02 \x01(\x01\"\xb0\x01\n\x12\x43hargeDataAccuracy\x12\x1b\n\x17\x43HARGE_DATA_ACCURACY_OK\x10\x00\x12+\n\'CHARGE_DATA_ACCURACY_CONSUMPTION_FAILED\x10\x01\x12*\n&CHARGE_DATA_ACCURACY_CHARGETIME_FAILED\x10\x02\x12$\n CHARGE_DATA_ACCURACY_BOTH_FAILED\x10\x03\x42\n\n\x08_trip_idB\r\n\x0b_origin_soc\"\x99\x02\n\x0cTripProgress\x12<\n\rnext_waypoint\x18\x01 \x01(\x0b\x32%.rivian.parallax.navigation.Timestamp\x12@\n\x11\x66inal_destination\x18\x02 \x01(\x0b\x32%.rivian.parallax.navigation.Timestamp\x12\x17\n\x0fnext_stop_index\x18\x03 \x01(\x05\x12\x1a\n\x12\x64istance_remaining\x18\x04 \x01(\x01\x12\x1a\n\x12\x64uration_remaining\x18\x05 \x01(\x01\x12\x38\n\x0clocation_fix\x18\x06 \x01(\x0b\x32\".rivian.parallax.navigation.GpsFix*r\n\nStopStatus\x12\x18\n\x14STOP_STATUS_STOPPING\x10\x00\x12\x17\n\x13STOP_STATUS_ARRIVED\x10\x01\x12\x19\n\x15STOP_STATUS_NEXT_STOP\x10\x02\x12\x16\n\x12STOP_STATUS_FUTURE\x10\x03\x62\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n&rivian/parallax/proto/navigation.proto\x12\x1arivian.parallax.navigation\"Y\n\rGeoCoordinate\x12\x15\n\x08latitude\x18\x01 \x01(\x01H\x00\x88\x01\x01\x12\x16\n\tlongitude\x18\x02 \x01(\x01H\x01\x88\x01\x01\x42\x0b\n\t_latitudeB\x0c\n\n_longitude\"K\n\tTimestamp\x12\x14\n\x07seconds\x18\x01 \x01(\x03H\x00\x88\x01\x01\x12\x12\n\x05nanos\x18\x02 \x01(\x05H\x01\x88\x01\x01\x42\n\n\x08_secondsB\x08\n\x06_nanos\"\xb6\x01\n\x06GpsFix\x12;\n\x08location\x18\x01 \x01(\x0b\x32).rivian.parallax.navigation.GeoCoordinate\x12\r\n\x05speed\x18\x02 \x01(\x02\x12\x0f\n\x07\x62\x65\x61ring\x18\x03 \x01(\x02\x12\x0f\n\x07offroad\x18\x04 \x01(\x08\x12\x11\n\x04time\x18\x05 \x01(\x03H\x00\x88\x01\x01\x12\x10\n\x08\x61ltitude\x18\x06 \x01(\x01\x12\x10\n\x08\x61\x63\x63uracy\x18\x07 \x01(\x01\x42\x07\n\x05_time\"\xd2!\n\x08TripInfo\x12\x14\n\x07trip_id\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x32\n\x06origin\x18\x02 \x01(\x0b\x32\".rivian.parallax.navigation.GpsFix\x12\x37\n\x04trip\x18\x03 \x01(\x0b\x32).rivian.parallax.navigation.TripInfo.Trip\x12P\n\x11route_preferences\x18\x04 \x01(\x0b\x32\x35.rivian.parallax.navigation.TripInfo.RoutePreferences\x12\x17\n\norigin_soc\x18\x05 \x01(\x01H\x01\x88\x01\x01\x12\x46\n\x17next_waypoint_departure\x18\x06 \x01(\x0b\x32%.rivian.parallax.navigation.Timestamp\x12\x46\n\x0c\x66\x61ster_route\x18\x07 \x01(\x0b\x32\x30.rivian.parallax.navigation.TripInfo.FasterRoute\x1a\xa8\x05\n\x04Trip\x12\x15\n\x08\x64istance\x18\x01 \x01(\x01H\x00\x88\x01\x01\x12\x15\n\x08\x64uration\x18\x02 \x01(\x01H\x01\x88\x01\x01\x12\x46\n\x08waypoint\x18\x03 \x03(\x0b\x32\x34.rivian.parallax.navigation.TripInfo.WaypointWrapper\x12\x35\n\x03leg\x18\x04 \x03(\x0b\x32(.rivian.parallax.navigation.TripInfo.Leg\x12\x1a\n\x12soc_is_below_limit\x18\x05 \x01(\x08\x12\x16\n\tfinal_soc\x18\x06 \x01(\x01H\x02\x88\x01\x01\x12\x18\n\x0b\x66inal_range\x18\x07 \x01(\x01H\x03\x88\x01\x01\x12I\n\x16\x62\x61ttery_empty_location\x18\x08 \x01(\x0b\x32).rivian.parallax.navigation.GeoCoordinate\x12-\n%battery_empty_to_destination_distance\x18\t \x01(\x01\x12\x1e\n\x11overview_polyline\x18\n \x01(\tH\x04\x88\x01\x01\x12Q\n\x12\x61lternative_routes\x18\x0b \x03(\x0b\x32\x35.rivian.parallax.navigation.TripInfo.AlternativeRoute\x12\x13\n\x0broute_index\x18\x0c \x01(\x05\x12U\n\x14\x61lternative_overlaps\x18\r \x03(\x0b\x32\x37.rivian.parallax.navigation.TripInfo.AlternativeOverlapB\x0b\n\t_distanceB\x0b\n\t_durationB\x0c\n\n_final_socB\x0e\n\x0c_final_rangeB\x14\n\x12_overview_polyline\x1a\x96\x01\n\x0fWaypointWrapper\x12\x39\n\x05place\x18\x01 \x01(\x0b\x32*.rivian.parallax.navigation.TripInfo.Place\x12H\n\rcharging_stop\x18\x02 \x01(\x0b\x32\x31.rivian.parallax.navigation.TripInfo.ChargingStop\x1a\xec\x02\n\x05Place\x12;\n\x08location\x18\x01 \x01(\x0b\x32).rivian.parallax.navigation.GeoCoordinate\x12\x43\n\x10snapped_location\x18\x02 \x01(\x0b\x32).rivian.parallax.navigation.GeoCoordinate\x12\x36\n\x06status\x18\x03 \x01(\x0e\x32&.rivian.parallax.navigation.StopStatus\x12\x11\n\x04name\x18\x04 \x01(\tH\x00\x88\x01\x01\x12\x15\n\x08place_id\x18\x05 \x01(\tH\x01\x88\x01\x01\x12\x15\n\rstop_duration\x18\x06 \x01(\x01\x12\x0b\n\x03soc\x18\x07 \x01(\x01\x12\r\n\x05range\x18\x08 \x01(\x01\x12\x36\n\x07\x61rrival\x18\t \x01(\x0b\x32%.rivian.parallax.navigation.TimestampB\x07\n\x05_nameB\x0b\n\t_place_id\x1a\xe4\x05\n\x0c\x43hargingStop\x12\x17\n\nstation_id\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x12\n\x05label\x18\x02 \x01(\tH\x01\x88\x01\x01\x12;\n\x08location\x18\x03 \x01(\x0b\x32).rivian.parallax.navigation.GeoCoordinate\x12\x1c\n\x0f\x63harge_duration\x18\x04 \x01(\x01H\x02\x88\x01\x01\x12U\n\x14\x63harge_data_accuracy\x18\x05 \x01(\x0e\x32\x37.rivian.parallax.navigation.TripInfo.ChargeDataAccuracy\x12\x18\n\x0b\x61rrival_soc\x18\x06 \x01(\x01H\x03\x88\x01\x01\x12\x1a\n\rarrival_range\x18\x07 \x01(\x01H\x04\x88\x01\x01\x12\x1a\n\rdeparture_soc\x18\x08 \x01(\x01H\x05\x88\x01\x01\x12\x1c\n\x0f\x64\x65parture_range\x18\t \x01(\x01H\x06\x88\x01\x01\x12=\n\x0e\x64\x65parture_time\x18\n \x01(\x0b\x32%.rivian.parallax.navigation.Timestamp\x12;\n\x0c\x61rrival_time\x18\x0b \x01(\x0b\x32%.rivian.parallax.navigation.Timestamp\x12\x17\n\x0fis_system_added\x18\x0c \x01(\x08\x12\x12\n\ncompatible\x18\r \x01(\x08\x12\x18\n\x10\x61\x64\x61pter_required\x18\x0e \x01(\x08\x12\x15\n\rstop_duration\x18\x10 \x01(\x01\x12\x36\n\x06status\x18\x11 \x01(\x0e\x32&.rivian.parallax.navigation.StopStatusB\r\n\x0b_station_idB\x08\n\x06_labelB\x12\n\x10_charge_durationB\x0e\n\x0c_arrival_socB\x10\n\x0e_arrival_rangeB\x10\n\x0e_departure_socB\x12\n\x10_departure_range\x1a\xdf\x07\n\x03Leg\x12\x10\n\x08\x64istance\x18\x01 \x01(\x01\x12\x10\n\x08\x64uration\x18\x02 \x01(\x01\x12\x17\n\nroad_label\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x15\n\x08polyline\x18\x04 \x01(\tH\x01\x88\x01\x01\x12\x14\n\x0ctraffic_slow\x18\x05 \x03(\x05\x12\x13\n\x0btraffic_jam\x18\x06 \x03(\x05\x12\x16\n\x0etraffic_severe\x18\x07 \x03(\x05\x12J\n\x06\x65nergy\x18\x08 \x01(\x0b\x32:.rivian.parallax.navigation.TripInfo.Leg.EnergyConsumption\x12\x44\n\tincidents\x18\t \x03(\x0b\x32\x31.rivian.parallax.navigation.TripInfo.Leg.Incident\x12M\n\x0etraffic_blocks\x18\n \x03(\x0b\x32\x35.rivian.parallax.navigation.TripInfo.Leg.TrafficBlock\x1a`\n\x11\x45nergyConsumption\x12\r\n\x05total\x18\x01 \x01(\x01\x12\x0f\n\x07thermal\x18\x02 \x01(\x01\x12\n\n\x02lv\x18\x03 \x01(\x01\x12\x0c\n\x04hvac\x18\x04 \x01(\x01\x12\x11\n\televation\x18\x05 \x01(\x01\x1a\x8a\x01\n\x08Incident\x12\x1c\n\x14polyline_first_index\x18\x01 \x01(\x05\x12\x1b\n\x13polyline_last_index\x18\x02 \x01(\x05\x12\x43\n\x04type\x18\x03 \x01(\x0e\x32\x35.rivian.parallax.navigation.TripInfo.Leg.IncidentType\x1a\x84\x01\n\x0cTrafficBlock\x12\x13\n\x0b\x66irst_index\x18\x01 \x01(\x05\x12\x1c\n\x14\x66irst_index_fraction\x18\x02 \x01(\x02\x12\x12\n\nlast_index\x18\x03 \x01(\x05\x12\x1b\n\x13last_index_fraction\x18\x04 \x01(\x02\x12\x10\n\x08priority\x18\x05 \x01(\x05\"\xcd\x01\n\x0cIncidentType\x12\x14\n\x10INCIDENT_UNKNOWN\x10\x00\x12\x11\n\rINCIDENT_NONE\x10\x01\x12\x14\n\x10INCIDENT_CLOSURE\x10\x02\x12\x12\n\x0eINCIDENT_CRASH\x10\x03\x12\x19\n\x15INCIDENT_CONSTRUCTION\x10\x04\x12\x10\n\x0cINCIDENT_JAM\x10\x05\x12\x1c\n\x18INCIDENT_POLICE_PRESENCE\x10\x06\x12\x1f\n\x1bINCIDENT_FIXED_SPEED_CAMERA\x10\x07\x42\r\n\x0b_road_labelB\x0b\n\t_polyline\x1a\xc7\x01\n\x10\x41lternativeRoute\x12\x35\n\x03leg\x18\x01 \x03(\x0b\x32(.rivian.parallax.navigation.TripInfo.Leg\x12\x10\n\x08polyline\x18\x02 \x01(\t\x12\x13\n\x0broute_index\x18\x03 \x01(\x05\x12U\n\x14\x61lternative_overlaps\x18\x04 \x03(\x0b\x32\x37.rivian.parallax.navigation.TripInfo.AlternativeOverlap\x1a\x93\x02\n\x12\x41lternativeOverlap\x12\x19\n\x11\x61lternative_index\x18\x01 \x01(\x05\x12\x61\n\x18not_overlapping_segments\x18\x02 \x03(\x0b\x32?.rivian.parallax.navigation.TripInfo.AlternativeOverlap.Segment\x1a\x7f\n\x07Segment\x12\x1d\n\x15start_drive_leg_index\x18\x01 \x01(\x05\x12\x1c\n\x14start_polyline_index\x18\x02 \x01(\x05\x12\x1b\n\x13\x65nd_drive_leg_index\x18\x03 \x01(\x05\x12\x1a\n\x12\x65nd_polyline_index\x18\x04 \x01(\x05\x1a\xc0\x01\n\x10RoutePreferences\x12 \n\x13\x63urrent_arrival_soc\x18\x01 \x01(\x02H\x00\x88\x01\x01\x12 \n\x13\x64\x65\x66\x61ult_arrival_soc\x18\x02 \x01(\x02H\x01\x88\x01\x01\x12\x16\n\x0eroad_avoidance\x18\x03 \x03(\t\x12 \n\x18\x63harging_network_filters\x18\x04 \x03(\tB\x16\n\x14_current_arrival_socB\x16\n\x14_default_arrival_soc\x1a[\n\x0b\x46\x61sterRoute\x12\x38\n\x05route\x18\x01 \x01(\x0b\x32).rivian.parallax.navigation.TripInfo.Trip\x12\x12\n\ntime_saved\x18\x02 \x01(\x01\"\xb0\x01\n\x12\x43hargeDataAccuracy\x12\x1b\n\x17\x43HARGE_DATA_ACCURACY_OK\x10\x00\x12+\n\'CHARGE_DATA_ACCURACY_CONSUMPTION_FAILED\x10\x01\x12*\n&CHARGE_DATA_ACCURACY_CHARGETIME_FAILED\x10\x02\x12$\n CHARGE_DATA_ACCURACY_BOTH_FAILED\x10\x03\x42\n\n\x08_trip_idB\r\n\x0b_origin_soc\"\x99\x02\n\x0cTripProgress\x12<\n\rnext_waypoint\x18\x01 \x01(\x0b\x32%.rivian.parallax.navigation.Timestamp\x12@\n\x11\x66inal_destination\x18\x02 \x01(\x0b\x32%.rivian.parallax.navigation.Timestamp\x12\x17\n\x0fnext_stop_index\x18\x03 \x01(\x05\x12\x1a\n\x12\x64istance_remaining\x18\x04 \x01(\x01\x12\x1a\n\x12\x64uration_remaining\x18\x05 \x01(\x01\x12\x38\n\x0clocation_fix\x18\x06 \x01(\x0b\x32\".rivian.parallax.navigation.GpsFix\"\x8b\x02\n\x0bUpdatedTrip\x12<\n\x05route\x18\x01 \x01(\x0b\x32-.rivian.parallax.navigation.UpdatedTrip.Route\x12\x42\n\x08settings\x18\x02 \x01(\x0b\x32\x30.rivian.parallax.navigation.UpdatedTrip.Settings\x12:\n\x04user\x18\x03 \x01(\x0b\x32,.rivian.parallax.navigation.UpdatedTrip.User\x1a\x19\n\x05Route\x12\x10\n\x08route_id\x18\x01 \x01(\t\x1a\n\n\x08Settings\x1a\x17\n\x04User\x12\x0f\n\x07user_id\x18\x01 \x01(\t*r\n\nStopStatus\x12\x18\n\x14STOP_STATUS_STOPPING\x10\x00\x12\x17\n\x13STOP_STATUS_ARRIVED\x10\x01\x12\x19\n\x15STOP_STATUS_NEXT_STOP\x10\x02\x12\x16\n\x12STOP_STATUS_FUTURE\x10\x03\x62\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'rivian.parallax.proto.navigation_pb2', _globals) if not _descriptor._USE_C_DESCRIPTORS: DESCRIPTOR._loaded_options = None - _globals['_STOPSTATUS']._serialized_start=5016 - _globals['_STOPSTATUS']._serialized_end=5130 + _globals['_STOPSTATUS']._serialized_start=5286 + _globals['_STOPSTATUS']._serialized_end=5400 _globals['_GEOCOORDINATE']._serialized_start=70 _globals['_GEOCOORDINATE']._serialized_end=159 _globals['_TIMESTAMP']._serialized_start=161 @@ -73,4 +73,12 @@ _globals['_TRIPINFO_CHARGEDATAACCURACY']._serialized_end=4703 _globals['_TRIPPROGRESS']._serialized_start=4733 _globals['_TRIPPROGRESS']._serialized_end=5014 + _globals['_UPDATEDTRIP']._serialized_start=5017 + _globals['_UPDATEDTRIP']._serialized_end=5284 + _globals['_UPDATEDTRIP_ROUTE']._serialized_start=5222 + _globals['_UPDATEDTRIP_ROUTE']._serialized_end=5247 + _globals['_UPDATEDTRIP_SETTINGS']._serialized_start=5249 + _globals['_UPDATEDTRIP_SETTINGS']._serialized_end=5259 + _globals['_UPDATEDTRIP_USER']._serialized_start=5261 + _globals['_UPDATEDTRIP_USER']._serialized_end=5284 # @@protoc_insertion_point(module_scope) diff --git a/src/rivian/parallax/proto/navigation_pb2.pyi b/src/rivian/parallax/proto/navigation_pb2.pyi index 3b0b27c..50e45f5 100644 --- a/src/rivian/parallax/proto/navigation_pb2.pyi +++ b/src/rivian/parallax/proto/navigation_pb2.pyi @@ -310,3 +310,26 @@ class TripProgress(_message.Message): duration_remaining: float location_fix: GpsFix def __init__(self, next_waypoint: _Optional[_Union[Timestamp, _Mapping]] = ..., final_destination: _Optional[_Union[Timestamp, _Mapping]] = ..., next_stop_index: _Optional[int] = ..., distance_remaining: _Optional[float] = ..., duration_remaining: _Optional[float] = ..., location_fix: _Optional[_Union[GpsFix, _Mapping]] = ...) -> None: ... + +class UpdatedTrip(_message.Message): + __slots__ = ("route", "settings", "user") + class Route(_message.Message): + __slots__ = ("route_id",) + ROUTE_ID_FIELD_NUMBER: _ClassVar[int] + route_id: str + def __init__(self, route_id: _Optional[str] = ...) -> None: ... + class Settings(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + class User(_message.Message): + __slots__ = ("user_id",) + USER_ID_FIELD_NUMBER: _ClassVar[int] + user_id: str + def __init__(self, user_id: _Optional[str] = ...) -> None: ... + ROUTE_FIELD_NUMBER: _ClassVar[int] + SETTINGS_FIELD_NUMBER: _ClassVar[int] + USER_FIELD_NUMBER: _ClassVar[int] + route: UpdatedTrip.Route + settings: UpdatedTrip.Settings + user: UpdatedTrip.User + def __init__(self, route: _Optional[_Union[UpdatedTrip.Route, _Mapping]] = ..., settings: _Optional[_Union[UpdatedTrip.Settings, _Mapping]] = ..., user: _Optional[_Union[UpdatedTrip.User, _Mapping]] = ...) -> None: ... diff --git a/src/rivian/rivian.py b/src/rivian/rivian.py index 0d0ebc6..9e4512c 100644 --- a/src/rivian/rivian.py +++ b/src/rivian/rivian.py @@ -34,8 +34,10 @@ from .parallax import ( CLIMATE_HOLD_SETTING_RVM, CLIMATE_HOLD_STATUS_RVM, + UPDATED_TRIP_MODEL, encode_climate_hold_setting, encode_command, + encode_end_trip, encode_vehicle_operation, ) from .utils import generate_vehicle_command_hmac, send_deprecation_warning @@ -72,6 +74,20 @@ } QUERY_TEMPLATE_MAP = TEMPLATE_MAP | {"gnssLocation": QUERY_LOCATION_TEMPLATE} +# Charging network ids by name, as the Rivian app's charging filter offers them; a +# trip plans with all of them unless the filter is set +CHARGING_NETWORKS: dict[str, tuple[str, ...]] = { + "rivian": ("10001", "10002"), # Adventure and Waypoints + "chargepoint": ("10025",), + "electrify_america": ("10027",), + "ev_connect": ("10030",), + "evgo": ("10031",), + "flo": ("10032", "10033"), + "shell_recharge": ("10045",), + "tesla": ("10050",), + "ionna": ("10056",), +} + # Minutes; the durations the Rivian app offers CLIMATE_HOLD_DURATIONS = range(120, 481, 15) @@ -665,6 +681,112 @@ async def send_location_to_vehicle( } return await self.__graphql_query(headers, url, graphql_json) + async def send_trip_to_vehicle( + self, + vehicle_id: str, + origin: tuple[float, float], + stops: list[tuple[str, float, float]], + min_arrival_soc: int = 20, + networks: list[str] | None = None, + ) -> bool: + """Send a trip to the vehicle's navigation, waking it if needed. + + `origin` is the vehicle's (latitude, longitude) and `stops` are (name, + latitude, longitude) in order. The vehicle plans the route and charging + stops itself, arriving with at least `min_arrival_soc` percent, at chargers + of the `CHARGING_NETWORKS` named in `networks` (default all). It replaces + any trip the vehicle is navigating. + """ + if not stops: + raise RivianBadRequestError("A trip needs at least one stop") + if not 0 <= min_arrival_soc <= 100: + raise RivianBadRequestError("Minimum arrival charge must be 0 to 100") + names = CHARGING_NETWORKS if networks is None else networks + if unknown := set(names) - CHARGING_NETWORKS.keys(): + raise RivianBadRequestError(f"Unknown charging networks: {sorted(unknown)}") + url = GRAPHQL_GATEWAY + headers = BASE_HEADERS | { + "Csrf-Token": self._csrf_token, + "A-Sess": self._app_session_token, + "U-Sess": self._user_session_token, + } + waypoints: list[dict[str, Any]] = [ + { + "address": { + "name": name, + "latitude": latitude, + "longitude": longitude, + "address": "", + "addressSourceId": "", + } + } + for name, latitude, longitude in [("Vehicle location", *origin), *stops] + ] + waypoints[-1]["minArrivalSoC"] = min_arrival_soc + graphql_json = { + "operationName": "sendTripToVehicle", + "query": "mutation sendTripToVehicle($attrs: SendTripToVehicleInput!) { sendTripToVehicle(attrs: $attrs) }", + "variables": { + "attrs": { + "preferences": { + "avoidAdapterRequired": False, + "networks": [ + {"networkId": network_id} + for name in names + for network_id in CHARGING_NETWORKS[name] + ], + }, + "requiresWakeup": True, + "routePreferences": { + "avoidFerries": False, + "avoidHighways": False, + "avoidTolls": False, + }, + "vehicleId": vehicle_id, + "waypoints": waypoints, + } + }, + } + + response = await self.__graphql_query(headers, url, graphql_json) + if response.status == 200: + data = await response.json() + return bool(data.get("data", {}).get("sendTripToVehicle")) + return False + + async def end_trip(self, vehicle_id: str, user_id: str) -> bool: + """End the trip the vehicle is navigating, waking it if needed. + + `user_id` is the signed-in user's id from `get_user_information`. + """ + 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": "SendUpdatedTrip", + "query": "mutation SendUpdatedTrip($payload: String!, $meta: ParallaxMetaInput!) { sendParallaxPayload(payload: $payload, meta: $meta) { success } }", + "variables": { + "payload": encode_end_trip(user_id), + "meta": { + "isVehicleModelOp": False, + "model": UPDATED_TRIP_MODEL, + "requiresWakeup": True, + "vehicleId": vehicle_id, + }, + }, + } + + response = await self.__graphql_query(headers, url, graphql_json) + if response.status == 200: + data = await response.json() + return bool( + (data.get("data", {}).get("sendParallaxPayload") or {}).get("success") + ) + return False + 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_navigation.py b/tests/parallax/test_navigation.py index 3e4a7eb..6e2e4cf 100644 --- a/tests/parallax/test_navigation.py +++ b/tests/parallax/test_navigation.py @@ -2,7 +2,10 @@ from __future__ import annotations -from rivian.parallax.proto import navigation_pb2 as nav +import base64 + +from rivian.parallax import encode_end_trip +from rivian.parallax.proto import command_pb2 as command, navigation_pb2 as nav from .helpers import decode, epoch @@ -181,3 +184,27 @@ def test_trip_progress() -> None: "bearing": 0.0, "locationTime": epoch(1790553428211), } + + +def test_encode_end_trip() -> None: + """Ending a trip sends an end_trip route, empty settings and the user.""" + encoded = encode_end_trip( + "user-1", + command_id="00010203-0405-0607-0809-0a0b0c0d0e0f", + timestamp=1762600000.25, + ) + envelope = command.Envelope.FromString(base64.b64decode(encoded)) + trip = nav.UpdatedTrip.FromString(envelope.composite.bytes_val) + assert trip.route.route_id == "end_trip_1762600000250" + assert trip.HasField("settings") + assert trip.user.user_id == "user-1" + assert (envelope.last_update.seconds, envelope.last_update.nanos) == ( + 1762600000, + 250000000, + ) + assert envelope.correlation_id == bytes(range(16)) + # The bytes the Rivian app's end trip has, with these ids + assert encoded == ( + "UigSJgoYChZlbmRfdHJpcF8xNzYyNjAwMDAwMjUwEgAaCAoGdXNlci0xYgsIwMi8yAYQgOWad4IB" + "EAABAgMEBQYHCAkKCwwNDg8=" + ) diff --git a/tests/test_rivian.py b/tests/test_rivian.py index 634571c..3ba3a96 100644 --- a/tests/test_rivian.py +++ b/tests/test_rivian.py @@ -32,6 +32,7 @@ ) from rivian.parallax import UNLOCK_RVM, encode_unlock from rivian.parallax.proto import command_pb2, operation_pb2 +from rivian.rivian import CHARGING_NETWORKS from rivian.utils import generate_key_pair from .responses import ( @@ -447,6 +448,59 @@ async def test_send_location_to_vehicle(aresponses: ResponsesMockServer) -> None await rivian.close() +async def test_trips(aresponses: ResponsesMockServer) -> None: + """Test sending and ending a trip.""" + requests = capture_requests( + aresponses, + {"data": {"sendTripToVehicle": True}}, + {"data": {"sendTripToVehicle": True}}, + {"data": {"sendTripToVehicle": False}}, + {"data": {"sendParallaxPayload": {"success": True}}}, + {"data": {"sendParallaxPayload": None}}, + ) + async with aiohttp.ClientSession(): + rivian = Rivian( + csrf_token="csrf", + app_session_token="token", + user_session_token="token", + ) + + stops = [("Stadium", 39.05, -94.48)] + assert await rivian.send_trip_to_vehicle("vehicle_id", (40.1, -92.5), stops, 30) + attrs = requests[0]["variables"]["attrs"] + waypoints = attrs["waypoints"] + assert [w["address"]["name"] for w in waypoints] == [ + "Vehicle location", + "Stadium", + ] + assert waypoints[-1]["minArrivalSoC"] == 30 + assert "minArrivalSoC" not in waypoints[0] + assert [n["networkId"] for n in attrs["preferences"]["networks"]] == [ + network_id for ids in CHARGING_NETWORKS.values() for network_id in ids + ] + + assert await rivian.send_trip_to_vehicle( + "vehicle_id", (40.1, -92.5), stops, networks=["rivian", "tesla"] + ) + networks = requests[1]["variables"]["attrs"]["preferences"]["networks"] + assert [n["networkId"] for n in networks] == ["10001", "10002", "10050"] + assert not await rivian.send_trip_to_vehicle("vehicle_id", (40.1, -92.5), stops) + for bad in ({"stops": []}, {"networks": ["x"]}, {"min_arrival_soc": 101}): + with pytest.raises(RivianBadRequestError): + await rivian.send_trip_to_vehicle( + "vehicle_id", (0, 0), **({"stops": stops} | bad) + ) + + assert await rivian.end_trip("vehicle_id", "user") + assert requests[3]["variables"]["meta"] == { + "isVehicleModelOp": False, + "model": "c2v_navigation.trip_copilot.updated_trip", + "requiresWakeup": True, + "vehicleId": "vehicle_id", + } + assert not await rivian.end_trip("vehicle_id", "user") + + async def test_subscribe_for_departure_schedules() -> None: """Test subscribing to departure schedules.""" rivian = Rivian(user_session_token="token")