Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/rivian/parallax/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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",
Expand Down
30 changes: 29 additions & 1 deletion src/rivian/parallax/navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,42 @@

from __future__ import annotations

import base64
import time
import uuid
from datetime import datetime
from typing import Any, Final

from google.protobuf.message import Message

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]:
Expand Down
17 changes: 17 additions & 0 deletions src/rivian/parallax/proto/navigation.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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_<epoch ms>" ends the trip
}
message Settings {}
message User {
string user_id = 1;
}
}
14 changes: 11 additions & 3 deletions src/rivian/parallax/proto/navigation_pb2.py

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions src/rivian/parallax/proto/navigation_pb2.pyi

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

122 changes: 122 additions & 0 deletions src/rivian/rivian.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
29 changes: 28 additions & 1 deletion tests/parallax/test_navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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="
)
54 changes: 54 additions & 0 deletions tests/test_rivian.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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")
Expand Down