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")