rivian-python-client 2.3.0__tar.gz → 2.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/PKG-INFO +1 -1
  2. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/__version__.py +1 -1
  3. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/departure.py +16 -6
  4. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/navigation.py +7 -3
  5. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/departure.proto +12 -0
  6. rivian_python_client-2.3.1/src/rivian/parallax/proto/departure_pb2.py +58 -0
  7. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/departure_pb2.pyi +16 -2
  8. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/rivian.py +104 -60
  9. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/ws_monitor.py +166 -33
  10. rivian_python_client-2.3.0/src/rivian/parallax/proto/departure_pb2.py +0 -54
  11. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/.gitignore +0 -0
  12. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/README.md +0 -0
  13. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/pyproject.toml +0 -0
  14. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/__init__.py +0 -0
  15. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/ble.py +0 -0
  16. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/const.py +0 -0
  17. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/exceptions.py +0 -0
  18. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/__init__.py +0 -0
  19. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/body.py +0 -0
  20. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/charging.py +0 -0
  21. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/comfort.py +0 -0
  22. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/core.py +0 -0
  23. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/device_table.py +0 -0
  24. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/dynamics.py +0 -0
  25. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/energy.py +0 -0
  26. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/energy_edge_compute.py +0 -0
  27. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/gearguard_streaming.py +0 -0
  28. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/geofence.py +0 -0
  29. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/holiday_celebration.py +0 -0
  30. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/operation.py +0 -0
  31. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/ota.py +0 -0
  32. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/parallax.py +0 -0
  33. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/__init__.py +0 -0
  34. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/body.proto +0 -0
  35. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/body_pb2.py +0 -0
  36. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/body_pb2.pyi +0 -0
  37. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/charging.proto +0 -0
  38. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/charging_pb2.py +0 -0
  39. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/charging_pb2.pyi +0 -0
  40. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/comfort.proto +0 -0
  41. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/comfort_pb2.py +0 -0
  42. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/comfort_pb2.pyi +0 -0
  43. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/device_table.proto +0 -0
  44. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/device_table_pb2.py +0 -0
  45. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/device_table_pb2.pyi +0 -0
  46. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/dynamics.proto +0 -0
  47. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/dynamics_pb2.py +0 -0
  48. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/dynamics_pb2.pyi +0 -0
  49. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/energy.proto +0 -0
  50. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/energy_edge_compute.proto +0 -0
  51. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/energy_edge_compute_pb2.py +0 -0
  52. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/energy_edge_compute_pb2.pyi +0 -0
  53. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/energy_pb2.py +0 -0
  54. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/energy_pb2.pyi +0 -0
  55. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/gearguard_streaming.proto +0 -0
  56. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/gearguard_streaming_pb2.py +0 -0
  57. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/gearguard_streaming_pb2.pyi +0 -0
  58. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/geofence.proto +0 -0
  59. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/geofence_pb2.py +0 -0
  60. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/geofence_pb2.pyi +0 -0
  61. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/holiday_celebration.proto +0 -0
  62. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/holiday_celebration_pb2.py +0 -0
  63. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/holiday_celebration_pb2.pyi +0 -0
  64. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/navigation.proto +0 -0
  65. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/navigation_pb2.py +0 -0
  66. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/navigation_pb2.pyi +0 -0
  67. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/operation.proto +0 -0
  68. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/operation_pb2.py +0 -0
  69. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/operation_pb2.pyi +0 -0
  70. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/ota.proto +0 -0
  71. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/ota_pb2.py +0 -0
  72. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/ota_pb2.pyi +0 -0
  73. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/parallax.proto +0 -0
  74. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/parallax_pb2.py +0 -0
  75. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/parallax_pb2.pyi +0 -0
  76. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/secure_file_transfer.proto +0 -0
  77. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/secure_file_transfer_pb2.py +0 -0
  78. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/secure_file_transfer_pb2.pyi +0 -0
  79. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/security.proto +0 -0
  80. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/security_pb2.py +0 -0
  81. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/security_pb2.pyi +0 -0
  82. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/user_passcodes.proto +0 -0
  83. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/user_passcodes_pb2.py +0 -0
  84. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/user_passcodes_pb2.pyi +0 -0
  85. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/vehicle.proto +0 -0
  86. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/vehicle_access.proto +0 -0
  87. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/vehicle_access_pb2.py +0 -0
  88. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/vehicle_access_pb2.pyi +0 -0
  89. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/vehicle_pb2.py +0 -0
  90. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/proto/vehicle_pb2.pyi +0 -0
  91. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/secure_file_transfer.py +0 -0
  92. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/security.py +0 -0
  93. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/user_passcodes.py +0 -0
  94. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/vehicle.py +0 -0
  95. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/parallax/vehicle_access.py +0 -0
  96. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/py.typed +0 -0
  97. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/schemas/charging.graphql +0 -0
  98. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/schemas/gateway.graphql +0 -0
  99. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/schemas/orders.graphql +0 -0
  100. {rivian_python_client-2.3.0 → rivian_python_client-2.3.1}/src/rivian/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: rivian-python-client
3
- Version: 2.3.0
3
+ Version: 2.3.1
4
4
  Summary: Rivian API Client (Unofficial)
5
5
  Author-email: Brian Retterer <bretterer@gmail.com>, "J.R. Gutierrez" <jrgutier@gmail.com>, Nathan Spencer <natekspencer@gmail.com>, Trevor Mack <drummer8001@gmail.com>
6
6
  License-Expression: MIT
@@ -1,4 +1,4 @@
1
1
  # This file is auto-generated by Hatchling. As such, do not:
2
2
  # - modify
3
3
  # - track in version control e.g. be sure to add to .gitignore
4
- __version__ = VERSION = '2.3.0'
4
+ __version__ = VERSION = '2.3.1'
@@ -53,11 +53,19 @@ def _decode_schedule(schedule: _Schedules.Schedule) -> dict[str, Any]:
53
53
  entry["name"] = v
54
54
  entry["isEnabled"] = schedule.is_enabled
55
55
 
56
- occurrence = schedule.occurrence
57
- entry["occurrence"] = {
58
- "days": [day for field, day in _WEEKDAYS if getattr(occurrence.days, field)],
59
- "startsAtMin": occurrence.starts_at,
60
- }
56
+ if schedule.HasField("single_occurrence"):
57
+ starts_at = schedule.single_occurrence.starts_at
58
+ entry["occurrence"] = {
59
+ "startsAt": from_epoch(starts_at.seconds + starts_at.nanos / 1e9)
60
+ }
61
+ else:
62
+ occurrence = schedule.occurrence
63
+ entry["occurrence"] = {
64
+ "days": [
65
+ day for field, day in _WEEKDAYS if getattr(occurrence.days, field)
66
+ ],
67
+ "startsAtMin": occurrence.starts_at,
68
+ }
61
69
 
62
70
  settings = schedule.departure_settings
63
71
  comfort = settings.comfort_settings
@@ -87,7 +95,9 @@ def decode_departure_schedules(m: departure_pb2.DepartureSchedules) -> dict[str,
87
95
  departureSchedules: list[dict] — the gateway's DepartureSchedule shape:
88
96
  id: str, name: str, isEnabled: bool
89
97
  occurrence: {"days": list[str] ("monday" ... "sunday"; order
90
- inferred), "startsAtMin": int (minutes after local midnight)}
98
+ inferred), "startsAtMin": int (minutes after local midnight)},
99
+ or {"startsAt": datetime} for the one-off "s-" schedule a
100
+ departNow adds (departing 10 minutes after the call)
91
101
  departureSettings: {"shouldOverrideChargeSchedule": bool,
92
102
  "comfortSettings": {"cabinTempCelsius": float,
93
103
  "frontDefogDefrost": "off" | "defog" | "defrost",
@@ -310,7 +310,9 @@ def decode_trip_info(m: navigation_pb2.TripInfo) -> dict[str, Any]:
310
310
  and incidents ({firstIndex, lastIndex, type})
311
311
  overviewPolyline: str — Google-encoded, whole trip
312
312
  nextWaypointDepartureTime: datetime — the next stop's departure if
313
- it's a charging stop, else its arrival
313
+ it's a charging stop, else its arrival. It stops updating once
314
+ the first stop is reached, keeping that stop's time for the
315
+ rest of the trip; use waypoints[0] instead
314
316
  finalStateOfCharge, finalRangeRemaining: float — the last
315
317
  waypoint's (percent, meters)
316
318
  socIsBelowLimit: bool
@@ -346,9 +348,11 @@ def decode_trip_progress(m: navigation_pb2.TripProgress) -> dict[str, Any]:
346
348
  """navigation.navigation_service.trip_progress — live progress on the active trip.
347
349
 
348
350
  Fields:
349
- nextWaypointArrivalTime: datetime — live ETA
351
+ nextWaypointArrivalTime: datetime — live ETA, to the minute
350
352
  finalDestinationArrivalTime: datetime — live ETA for the last
351
- waypoint; missing for one message after the waypoints change
353
+ waypoint, to the minute; missing while the route is
354
+ recalculating and on the stale progress the vehicle keeps
355
+ resending after navigation ends
352
356
  nextStopIndex: int
353
357
  distanceRemaining, durationRemaining: float — to the next waypoint
354
358
  (meters, seconds)
@@ -15,9 +15,21 @@ message DepartureSchedules {
15
15
  optional string name = 2;
16
16
  bool is_enabled = 3;
17
17
  Occurrence occurrence = 4;
18
+ // Set instead of occurrence on the one-off "s-" schedule a departNow
19
+ // adds; the gateway leaves that schedule out.
20
+ SingleOccurrence single_occurrence = 5;
18
21
  DepartureSettings departure_settings = 6;
19
22
  }
20
23
 
24
+ message SingleOccurrence {
25
+ StartsAt starts_at = 1;
26
+
27
+ message StartsAt {
28
+ int64 seconds = 1; // epoch seconds
29
+ int32 nanos = 2;
30
+ }
31
+ }
32
+
21
33
  message Occurrence {
22
34
  Days days = 1;
23
35
  optional int32 starts_at = 2; // minutes after local midnight
@@ -0,0 +1,58 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Generated by the protocol buffer compiler. DO NOT EDIT!
3
+ # NO CHECKED-IN PROTOBUF GENCODE
4
+ # source: rivian/parallax/proto/departure.proto
5
+ # Protobuf Python Version: 6.31.1
6
+ """Generated protocol buffer code."""
7
+ from google.protobuf import descriptor as _descriptor
8
+ from google.protobuf import descriptor_pool as _descriptor_pool
9
+ from google.protobuf import runtime_version as _runtime_version
10
+ from google.protobuf import symbol_database as _symbol_database
11
+ from google.protobuf.internal import builder as _builder
12
+ _runtime_version.ValidateProtobufRuntimeVersion(
13
+ _runtime_version.Domain.PUBLIC,
14
+ 6,
15
+ 31,
16
+ 1,
17
+ '',
18
+ 'rivian/parallax/proto/departure.proto'
19
+ )
20
+ # @@protoc_insertion_point(imports)
21
+
22
+ _sym_db = _symbol_database.Default()
23
+
24
+
25
+
26
+
27
+ DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n%rivian/parallax/proto/departure.proto\x12\x19rivian.parallax.departure\"\xca\r\n\x12\x44\x65partureSchedules\x12H\n\x08schedule\x18\x01 \x03(\x0b\x32\x36.rivian.parallax.departure.DepartureSchedules.Schedule\x12K\n\nupdated_at\x18\x02 \x01(\x0b\x32\x37.rivian.parallax.departure.DepartureSchedules.UpdatedAt\x1a\xd8\x02\n\x08Schedule\x12\x0f\n\x02id\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x11\n\x04name\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x12\n\nis_enabled\x18\x03 \x01(\x08\x12L\n\noccurrence\x18\x04 \x01(\x0b\x32\x38.rivian.parallax.departure.DepartureSchedules.Occurrence\x12Y\n\x11single_occurrence\x18\x05 \x01(\x0b\x32>.rivian.parallax.departure.DepartureSchedules.SingleOccurrence\x12[\n\x12\x64\x65parture_settings\x18\x06 \x01(\x0b\x32?.rivian.parallax.departure.DepartureSchedules.DepartureSettingsB\x05\n\x03_idB\x07\n\x05_name\x1a\x9a\x01\n\x10SingleOccurrence\x12Z\n\tstarts_at\x18\x01 \x01(\x0b\x32G.rivian.parallax.departure.DepartureSchedules.SingleOccurrence.StartsAt\x1a*\n\x08StartsAt\x12\x0f\n\x07seconds\x18\x01 \x01(\x03\x12\r\n\x05nanos\x18\x02 \x01(\x05\x1at\n\nOccurrence\x12@\n\x04\x64\x61ys\x18\x01 \x01(\x0b\x32\x32.rivian.parallax.departure.DepartureSchedules.Days\x12\x16\n\tstarts_at\x18\x02 \x01(\x05H\x00\x88\x01\x01\x42\x0c\n\n_starts_at\x1a~\n\x04\x44\x61ys\x12\x0e\n\x06monday\x18\x01 \x01(\x08\x12\x0f\n\x07tuesday\x18\x02 \x01(\x08\x12\x11\n\twednesday\x18\x03 \x01(\x08\x12\x10\n\x08thursday\x18\x04 \x01(\x08\x12\x0e\n\x06\x66riday\x18\x05 \x01(\x08\x12\x10\n\x08saturday\x18\x06 \x01(\x08\x12\x0e\n\x06sunday\x18\x07 \x01(\x08\x1a\x95\x01\n\x11\x44\x65partureSettings\x12W\n\x10\x63omfort_settings\x18\x01 \x01(\x0b\x32=.rivian.parallax.departure.DepartureSchedules.ComfortSettings\x12\'\n\x1fshould_override_charge_schedule\x18\x02 \x01(\x08\x1a\xde\x01\n\x0f\x43omfortSettings\x12\x19\n\x11\x63\x61\x62in_temperature\x18\x01 \x01(\x02\x12I\n\x13\x66ront_defog_defrost\x18\x02 \x01(\x0e\x32,.rivian.parallax.departure.DefogDefrostLevel\x12\x65\n\x18surface_heat_vent_levels\x18\x03 \x01(\x0b\x32\x43.rivian.parallax.departure.DepartureSchedules.SurfaceHeatVentLevels\x1a\x88\x03\n\x15SurfaceHeatVentLevels\x12H\n\x0f\x66ront_left_seat\x18\x01 \x01(\x0e\x32/.rivian.parallax.departure.SurfaceHeatVentLevel\x12I\n\x10\x66ront_right_seat\x18\x02 \x01(\x0e\x32/.rivian.parallax.departure.SurfaceHeatVentLevel\x12G\n\x0erear_left_seat\x18\x03 \x01(\x0e\x32/.rivian.parallax.departure.SurfaceHeatVentLevel\x12H\n\x0frear_right_seat\x18\x04 \x01(\x0e\x32/.rivian.parallax.departure.SurfaceHeatVentLevel\x12G\n\x0esteering_wheel\x18\x05 \x01(\x0e\x32/.rivian.parallax.departure.SurfaceHeatVentLevel\x1a+\n\tUpdatedAt\x12\x0f\n\x07seconds\x18\x01 \x01(\x03\x12\r\n\x05nanos\x18\x02 \x01(\x05*^\n\x11\x44\x65\x66ogDefrostLevel\x12\x15\n\x11\x44\x45\x46OG_DEFROST_OFF\x10\x00\x12\x17\n\x13\x44\x45\x46OG_DEFROST_DEFOG\x10\x01\x12\x19\n\x15\x44\x45\x46OG_DEFROST_DEFROST\x10\x02*\x9f\x01\n\x14SurfaceHeatVentLevel\x12\x0f\n\x0bSURFACE_OFF\x10\x00\x12\x12\n\x0eSURFACE_HEAT_1\x10\x01\x12\x12\n\x0eSURFACE_HEAT_2\x10\x02\x12\x12\n\x0eSURFACE_HEAT_3\x10\x03\x12\x12\n\x0eSURFACE_VENT_1\x10\x04\x12\x12\n\x0eSURFACE_VENT_2\x10\x05\x12\x12\n\x0eSURFACE_VENT_3\x10\x06\x62\x06proto3')
28
+
29
+ _globals = globals()
30
+ _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
31
+ _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'rivian.parallax.proto.departure_pb2', _globals)
32
+ if not _descriptor._USE_C_DESCRIPTORS:
33
+ DESCRIPTOR._loaded_options = None
34
+ _globals['_DEFOGDEFROSTLEVEL']._serialized_start=1809
35
+ _globals['_DEFOGDEFROSTLEVEL']._serialized_end=1903
36
+ _globals['_SURFACEHEATVENTLEVEL']._serialized_start=1906
37
+ _globals['_SURFACEHEATVENTLEVEL']._serialized_end=2065
38
+ _globals['_DEPARTURESCHEDULES']._serialized_start=69
39
+ _globals['_DEPARTURESCHEDULES']._serialized_end=1807
40
+ _globals['_DEPARTURESCHEDULES_SCHEDULE']._serialized_start=243
41
+ _globals['_DEPARTURESCHEDULES_SCHEDULE']._serialized_end=587
42
+ _globals['_DEPARTURESCHEDULES_SINGLEOCCURRENCE']._serialized_start=590
43
+ _globals['_DEPARTURESCHEDULES_SINGLEOCCURRENCE']._serialized_end=744
44
+ _globals['_DEPARTURESCHEDULES_SINGLEOCCURRENCE_STARTSAT']._serialized_start=702
45
+ _globals['_DEPARTURESCHEDULES_SINGLEOCCURRENCE_STARTSAT']._serialized_end=744
46
+ _globals['_DEPARTURESCHEDULES_OCCURRENCE']._serialized_start=746
47
+ _globals['_DEPARTURESCHEDULES_OCCURRENCE']._serialized_end=862
48
+ _globals['_DEPARTURESCHEDULES_DAYS']._serialized_start=864
49
+ _globals['_DEPARTURESCHEDULES_DAYS']._serialized_end=990
50
+ _globals['_DEPARTURESCHEDULES_DEPARTURESETTINGS']._serialized_start=993
51
+ _globals['_DEPARTURESCHEDULES_DEPARTURESETTINGS']._serialized_end=1142
52
+ _globals['_DEPARTURESCHEDULES_COMFORTSETTINGS']._serialized_start=1145
53
+ _globals['_DEPARTURESCHEDULES_COMFORTSETTINGS']._serialized_end=1367
54
+ _globals['_DEPARTURESCHEDULES_SURFACEHEATVENTLEVELS']._serialized_start=1370
55
+ _globals['_DEPARTURESCHEDULES_SURFACEHEATVENTLEVELS']._serialized_end=1762
56
+ _globals['_DEPARTURESCHEDULES_UPDATEDAT']._serialized_start=1764
57
+ _globals['_DEPARTURESCHEDULES_UPDATEDAT']._serialized_end=1807
58
+ # @@protoc_insertion_point(module_scope)
@@ -36,18 +36,32 @@ SURFACE_VENT_3: SurfaceHeatVentLevel
36
36
  class DepartureSchedules(_message.Message):
37
37
  __slots__ = ("schedule", "updated_at")
38
38
  class Schedule(_message.Message):
39
- __slots__ = ("id", "name", "is_enabled", "occurrence", "departure_settings")
39
+ __slots__ = ("id", "name", "is_enabled", "occurrence", "single_occurrence", "departure_settings")
40
40
  ID_FIELD_NUMBER: _ClassVar[int]
41
41
  NAME_FIELD_NUMBER: _ClassVar[int]
42
42
  IS_ENABLED_FIELD_NUMBER: _ClassVar[int]
43
43
  OCCURRENCE_FIELD_NUMBER: _ClassVar[int]
44
+ SINGLE_OCCURRENCE_FIELD_NUMBER: _ClassVar[int]
44
45
  DEPARTURE_SETTINGS_FIELD_NUMBER: _ClassVar[int]
45
46
  id: str
46
47
  name: str
47
48
  is_enabled: bool
48
49
  occurrence: DepartureSchedules.Occurrence
50
+ single_occurrence: DepartureSchedules.SingleOccurrence
49
51
  departure_settings: DepartureSchedules.DepartureSettings
50
- def __init__(self, id: _Optional[str] = ..., name: _Optional[str] = ..., is_enabled: bool = ..., occurrence: _Optional[_Union[DepartureSchedules.Occurrence, _Mapping]] = ..., departure_settings: _Optional[_Union[DepartureSchedules.DepartureSettings, _Mapping]] = ...) -> None: ...
52
+ def __init__(self, id: _Optional[str] = ..., name: _Optional[str] = ..., is_enabled: bool = ..., occurrence: _Optional[_Union[DepartureSchedules.Occurrence, _Mapping]] = ..., single_occurrence: _Optional[_Union[DepartureSchedules.SingleOccurrence, _Mapping]] = ..., departure_settings: _Optional[_Union[DepartureSchedules.DepartureSettings, _Mapping]] = ...) -> None: ...
53
+ class SingleOccurrence(_message.Message):
54
+ __slots__ = ("starts_at",)
55
+ class StartsAt(_message.Message):
56
+ __slots__ = ("seconds", "nanos")
57
+ SECONDS_FIELD_NUMBER: _ClassVar[int]
58
+ NANOS_FIELD_NUMBER: _ClassVar[int]
59
+ seconds: int
60
+ nanos: int
61
+ def __init__(self, seconds: _Optional[int] = ..., nanos: _Optional[int] = ...) -> None: ...
62
+ STARTS_AT_FIELD_NUMBER: _ClassVar[int]
63
+ starts_at: DepartureSchedules.SingleOccurrence.StartsAt
64
+ def __init__(self, starts_at: _Optional[_Union[DepartureSchedules.SingleOccurrence.StartsAt, _Mapping]] = ...) -> None: ...
51
65
  class Occurrence(_message.Message):
52
66
  __slots__ = ("days", "starts_at")
53
67
  DAYS_FIELD_NUMBER: _ClassVar[int]
@@ -133,6 +133,32 @@ class Rivian:
133
133
  self._ws_monitor: WebSocketMonitor | None = None
134
134
  self._subscriptions: dict[str, str] = {}
135
135
 
136
+ @property
137
+ def ws_backoff_remaining(self) -> float:
138
+ """Seconds until the web socket may reconnect after a failed connection.
139
+
140
+ 0 when it isn't backing off. While it is, subscribe_for_* fail without
141
+ connecting.
142
+ """
143
+ return self._ws_monitor.backoff_remaining if self._ws_monitor else 0
144
+
145
+ @property
146
+ def ws_rate_limited(self) -> bool:
147
+ """Whether the web socket is backing off after a rate limit.
148
+
149
+ True for the rest of the backoff, including on a new client for the same
150
+ session; False when backing off for a missing acknowledgement instead.
151
+ """
152
+ return self._ws_monitor.rate_limited if self._ws_monitor else False
153
+
154
+ @property
155
+ def ws_unauthenticated(self) -> bool:
156
+ """Whether the server closed the latest web socket as unauthenticated.
157
+
158
+ The session needs a new login; the web socket doesn't reconnect.
159
+ """
160
+ return self._ws_monitor.unauthenticated if self._ws_monitor else False
161
+
136
162
  async def create_csrf_token(self) -> None:
137
163
  """Create cross-site-request-forgery (csrf) token."""
138
164
  url = GRAPHQL_GATEWAY
@@ -812,22 +838,14 @@ class Rivian:
812
838
  if not properties:
813
839
  properties = VEHICLE_STATES_SUBSCRIPTION_PROPERTIES
814
840
 
815
- try:
816
- await self._ws_connect()
817
- assert self._ws_monitor
818
- async with asyncio.timeout(self.request_timeout):
819
- await self._ws_monitor.connection_ack.wait()
820
- payload = {
821
- "operationName": "VehicleState",
822
- "query": f"subscription VehicleState($vehicleID: String!) {{ vehicleState(id: $vehicleID) {self._build_vehicle_state_fragment(properties)} }}",
823
- "variables": {"vehicleID": vehicle_id},
824
- }
825
- unsubscribe = await self._ws_monitor.start_subscription(payload, callback)
826
- _LOGGER.debug("%s subscribed to updates", vehicle_id)
827
- return unsubscribe
828
- except Exception as ex: # pylint: disable=broad-except # noqa: BLE001
829
- _LOGGER.error(ex)
830
- return None
841
+ payload = {
842
+ "operationName": "VehicleState",
843
+ "query": f"subscription VehicleState($vehicleID: String!) {{ vehicleState(id: $vehicleID) {self._build_vehicle_state_fragment(properties)} }}",
844
+ "variables": {"vehicleID": vehicle_id},
845
+ }
846
+ return await self._start_subscription(
847
+ payload, callback, f"{vehicle_id} vehicle updates"
848
+ )
831
849
 
832
850
  async def subscribe_for_departure_schedules(
833
851
  self, vehicle_id: str, callback: Callable[[dict[str, Any]], None]
@@ -836,22 +854,14 @@ class Rivian:
836
854
 
837
855
  The current schedules are sent right after subscribing.
838
856
  """
839
- try:
840
- await self._ws_connect()
841
- assert self._ws_monitor
842
- async with asyncio.timeout(self.request_timeout):
843
- await self._ws_monitor.connection_ack.wait()
844
- payload = {
845
- "operationName": "VehicleDepartureSchedules",
846
- "query": "subscription VehicleDepartureSchedules($vehicleId: String!) { vehicleDepartureSchedules(vehicleId: $vehicleId) { id name isEnabled occurrence { __typename ... on RepeatsWeekly { days startsAtMin skippedOn } } departureSettings { shouldOverrideChargeSchedule comfortSettings { cabinTempCelsius frontDefogDefrost surfaceHeatVentLevels { frontLeftSeat frontRightSeat rearLeftSeat rearRightSeat steeringWheel } } } } }",
847
- "variables": {"vehicleId": vehicle_id},
848
- }
849
- unsubscribe = await self._ws_monitor.start_subscription(payload, callback)
850
- _LOGGER.debug("%s subscribed to departure schedules", vehicle_id)
851
- return unsubscribe
852
- except Exception as ex: # pylint: disable=broad-except # noqa: BLE001
853
- _LOGGER.error(ex)
854
- return None
857
+ payload = {
858
+ "operationName": "VehicleDepartureSchedules",
859
+ "query": "subscription VehicleDepartureSchedules($vehicleId: String!) { vehicleDepartureSchedules(vehicleId: $vehicleId) { id name isEnabled occurrence { __typename ... on RepeatsWeekly { days startsAtMin skippedOn } } departureSettings { shouldOverrideChargeSchedule comfortSettings { cabinTempCelsius frontDefogDefrost surfaceHeatVentLevels { frontLeftSeat frontRightSeat rearLeftSeat rearRightSeat steeringWheel } } } } }",
860
+ "variables": {"vehicleId": vehicle_id},
861
+ }
862
+ return await self._start_subscription(
863
+ payload, callback, f"{vehicle_id} departure schedules"
864
+ )
855
865
 
856
866
  async def subscribe_for_parallax_messages(
857
867
  self,
@@ -866,32 +876,60 @@ class Rivian:
866
876
  no decoder yet (`decode_parallax_message` returns None for those).
867
877
  Pass a list, e.g. `PARALLAX_RVMS`, to only receive those topics.
868
878
  """
879
+ variables: dict[str, Any] = {"vehicleId": vehicle_id}
880
+ if rvms is not None:
881
+ variables["rvms"] = rvms
882
+ payload = {
883
+ "operationName": "ParallaxMessages",
884
+ "query": "subscription ParallaxMessages($vehicleId: String!, $rvms: [String!]) { parallaxMessages(vehicleId: $vehicleId, rvms: $rvms) { payload timestamp rvm } }",
885
+ "variables": variables,
886
+ }
887
+ topics = "all Parallax RVMs" if rvms is None else f"{len(rvms)} Parallax RVMs"
888
+ return await self._start_subscription(
889
+ payload, callback, f"{vehicle_id} {topics}"
890
+ )
891
+
892
+ async def _start_subscription(
893
+ self,
894
+ payload: dict[str, Any],
895
+ callback: Callable[[dict[str, Any]], None],
896
+ description: str,
897
+ ) -> Callable[[], Awaitable[None]] | None:
898
+ """Connect the web socket if needed and start a subscription.
899
+
900
+ Returns the unsubscribe function, or None (after logging why) on failure.
901
+ """
902
+ reason: str | Exception
869
903
  try:
870
- await self._ws_connect()
871
- assert self._ws_monitor
872
- async with asyncio.timeout(self.request_timeout):
873
- await self._ws_monitor.connection_ack.wait()
874
- variables: dict[str, Any] = {"vehicleId": vehicle_id}
875
- if rvms is not None:
876
- variables["rvms"] = rvms
877
- payload = {
878
- "operationName": "ParallaxMessages",
879
- "query": "subscription ParallaxMessages($vehicleId: String!, $rvms: [String!]) { parallaxMessages(vehicleId: $vehicleId, rvms: $rvms) { payload timestamp rvm } }",
880
- "variables": variables,
881
- }
882
- unsubscribe = await self._ws_monitor.start_subscription(payload, callback)
883
- _LOGGER.debug(
884
- "%s subscribed to %s",
885
- vehicle_id,
886
- "all Parallax RVMs" if rvms is None else f"{len(rvms)} Parallax RVMs",
887
- )
888
- return unsubscribe
904
+ ws_monitor = await self._ws_connect()
905
+ # Ends early if the connection closes, e.g. when rate limited
906
+ if await ws_monitor.wait_for_ack() and (
907
+ unsubscribe := await ws_monitor.start_subscription(payload, callback)
908
+ ):
909
+ _LOGGER.debug("Subscribed to %s", description)
910
+ return unsubscribe
911
+ if backoff_reason := ws_monitor.backoff_reason:
912
+ reason = f"the web socket connection {backoff_reason}"
913
+ elif close_message := ws_monitor.close_message:
914
+ reason = f"the web socket connection closed ({close_message})"
915
+ else:
916
+ reason = "the web socket connection closed"
889
917
  except Exception as ex: # pylint: disable=broad-except # noqa: BLE001
890
- _LOGGER.error(ex)
891
- return None
918
+ reason = ex
919
+ # Expected while backing off, so less severe than other failures
920
+ _LOGGER.log(
921
+ logging.WARNING if self.ws_backoff_remaining else logging.ERROR,
922
+ "Unable to subscribe to %s: %s",
923
+ description,
924
+ reason,
925
+ )
926
+ if self._ws_monitor and not self._ws_monitor.has_subscriptions:
927
+ # Don't leave a connection open, or reconnecting, with nothing on it
928
+ await self._ws_monitor.close()
929
+ return None
892
930
 
893
- async def _ws_connect(self) -> ClientWebSocketResponse[bool]:
894
- """Initiate a websocket connection."""
931
+ async def _ws_connect(self) -> WebSocketMonitor:
932
+ """Connect the web socket if needed and return its monitor."""
895
933
 
896
934
  async def connection_init(websocket: ClientWebSocketResponse[bool]) -> None:
897
935
  await websocket.send_json(
@@ -908,17 +946,23 @@ class Rivian:
908
946
 
909
947
  if not self._ws_monitor:
910
948
  self._ws_monitor = WebSocketMonitor(
911
- self, GRAPHQL_WEBSOCKET, connection_init
949
+ self,
950
+ GRAPHQL_WEBSOCKET,
951
+ connection_init,
952
+ backoff_key=self._user_session_token,
912
953
  )
913
954
  ws_monitor = self._ws_monitor
914
- if ws_monitor.backing_off:
915
- raise RivianApiException("Web socket is waiting to reconnect")
916
955
  if ws_monitor.websocket is None or ws_monitor.websocket.closed:
956
+ # A backoff only holds off new connections, not an open one
957
+ if remaining := ws_monitor.backoff_remaining:
958
+ raise RivianApiException(
959
+ "the web socket is waiting to reconnect "
960
+ f"({remaining:.0f} seconds left)"
961
+ )
917
962
  await ws_monitor.new_connection(True)
918
- assert ws_monitor.websocket
919
963
  if ws_monitor.monitor is None or ws_monitor.monitor.done():
920
964
  await ws_monitor.start_monitor()
921
- return ws_monitor.websocket
965
+ return ws_monitor
922
966
 
923
967
  async def __graphql_query(
924
968
  self, headers: dict[str, str], url: str, body: dict[str, Any]
@@ -5,11 +5,13 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import inspect
7
7
  import logging
8
- from collections.abc import Awaitable, Callable
8
+ import time
9
+ from collections.abc import Awaitable, Callable, Hashable
10
+ from dataclasses import dataclass
9
11
  from datetime import UTC, datetime
10
12
  from json import loads
11
13
  from random import uniform
12
- from typing import TYPE_CHECKING, Any
14
+ from typing import TYPE_CHECKING, Any, ClassVar
13
15
  from uuid import uuid4
14
16
 
15
17
  from aiohttp import ClientWebSocketResponse, WSMessage, WSMsgType
@@ -21,14 +23,20 @@ if TYPE_CHECKING:
21
23
  _LOGGER = logging.getLogger(__name__)
22
24
 
23
25
  RATE_LIMITED = "Rate limited"
26
+ UNAUTHENTICATED = "Unauthenticated"
24
27
  RATE_LIMIT_BACKOFF_MIN = 30
25
28
  RATE_LIMIT_BACKOFF_MAX = 900
26
29
 
30
+ # Backoff reasons, completing "the web socket connection ..."
31
+ BACKOFF_RATE_LIMITED = "was rate limited"
32
+ BACKOFF_NOT_ACKNOWLEDGED = "was not acknowledged"
33
+
27
34
 
28
35
  async def cancel_task(*tasks: asyncio.Task | None) -> None:
29
- """Cancel task(s)."""
36
+ """Cancel task(s), other than the one running this."""
37
+ current = asyncio.current_task()
30
38
  for task in tasks:
31
- if task is not None and not task.done():
39
+ if task is not None and task is not current and not task.done():
32
40
  task.cancel()
33
41
  try:
34
42
  await task
@@ -36,24 +44,52 @@ async def cancel_task(*tasks: asyncio.Task | None) -> None:
36
44
  pass
37
45
 
38
46
 
47
+ @dataclass
48
+ class _Backoff:
49
+ """Backoff state: consecutive failed connections and when to retry."""
50
+
51
+ attempt: int = 0
52
+ retry_at: float = 0 # time.monotonic()
53
+ rate_limited: bool = False # whether the backoff until retry_at is a rate limit
54
+
55
+
39
56
  class WebSocketMonitor:
40
57
  """Web socket monitor for a vehicle."""
41
58
 
59
+ # Backoff state by key, shared so a new monitor for the same session doesn't
60
+ # connect while another is waiting out a rate limit
61
+ _shared_backoffs: ClassVar[dict[Hashable, _Backoff]] = {}
62
+
42
63
  def __init__(
43
64
  self,
44
65
  account: Rivian,
45
66
  url: str,
46
67
  connection_init: Callable[[ClientWebSocketResponse[bool]], Awaitable[None]],
68
+ backoff_key: Hashable | None = None,
47
69
  ) -> None:
48
- """Initialize a web socket monitor."""
70
+ """Initialize a web socket monitor.
71
+
72
+ Monitors with the same `backoff_key` (e.g. the user session) share their
73
+ backoff state; without one, the state is the monitor's own.
74
+ """
49
75
  self._account = account
50
76
  self._url = url
51
77
  self._connection_init = connection_init
52
78
 
53
79
  self._connection_ack: asyncio.Event = asyncio.Event()
80
+ # Set while there's no open connection
81
+ self._closed: asyncio.Event = asyncio.Event()
82
+ self._closed.set()
54
83
  self._disconnect = False
84
+ # Why the current connection is backing off, at most once per connection
55
85
  self._backoff_reason: str | None = None
56
- self._rate_limit_attempt = 0
86
+ # The server's reason for closing the current connection, if it gave one
87
+ self._close_message: str | None = None
88
+ self._backoff_state = (
89
+ _Backoff()
90
+ if backoff_key is None
91
+ else self._shared_backoffs.setdefault(backoff_key, _Backoff())
92
+ )
57
93
  self._ws: ClientWebSocketResponse[bool] | None = None
58
94
  self._monitor_task: asyncio.Task | None = None
59
95
  self._receiver_task: asyncio.Task | None = None
@@ -87,12 +123,47 @@ class WebSocketMonitor:
87
123
  @property
88
124
  def backing_off(self) -> bool:
89
125
  """Return `True` while waiting to reconnect after a failed connection."""
90
- return self._backoff_reason is not None
126
+ return self.backoff_remaining > 0
127
+
128
+ @property
129
+ def backoff_reason(self) -> str | None:
130
+ """Return why the latest connection is backing off, if it is."""
131
+ return self._backoff_reason
132
+
133
+ @property
134
+ def close_message(self) -> str | None:
135
+ """Return the server's reason for closing the latest connection, if any."""
136
+ return self._close_message
137
+
138
+ @property
139
+ def rate_limited(self) -> bool:
140
+ """Return `True` while backing off after a rate limit.
141
+
142
+ Shared like the backoff, so a new monitor for the same session sees it too.
143
+ """
144
+ return self._backoff_state.rate_limited and self.backoff_remaining > 0
145
+
146
+ @property
147
+ def unauthenticated(self) -> bool:
148
+ """Return `True` if the server closed the latest connection as unauthenticated."""
149
+ return self._close_message == UNAUTHENTICATED
150
+
151
+ @property
152
+ def backoff_remaining(self) -> float:
153
+ """Return the seconds left before reconnecting is allowed."""
154
+ return max(self._backoff_state.retry_at - time.monotonic(), 0)
155
+
156
+ @property
157
+ def has_subscriptions(self) -> bool:
158
+ """Return `True` if there are subscriptions to resume on reconnect."""
159
+ return bool(self._subscriptions)
91
160
 
92
161
  async def new_connection(self, start_monitor: bool = False) -> None:
93
162
  """Create a new connection and, optionally, start the monitor."""
94
163
  await cancel_task(self._receiver_task)
95
164
  self._disconnect = False
165
+ self._backoff_reason = None
166
+ self._close_message = None
96
167
  self._connection_ack.clear()
97
168
  # pylint: disable=protected-access
98
169
  assert self._account._session
@@ -100,10 +171,36 @@ class WebSocketMonitor:
100
171
  url=self._url, headers={"sec-websocket-protocol": "graphql-transport-ws"}
101
172
  )
102
173
  await self._connection_init(self._ws)
174
+ self._closed.clear()
103
175
  self._receiver_task = asyncio.ensure_future(self._receiver())
104
176
  if start_monitor:
105
177
  await self.start_monitor()
106
178
 
179
+ async def wait_for_ack(self) -> bool:
180
+ """Wait for the connection to be acknowledged or to close.
181
+
182
+ Returns whether it was acknowledged. A connection that isn't acknowledged
183
+ within the request timeout is closed and backed off.
184
+ """
185
+ if not self._connection_ack.is_set():
186
+ waits = [
187
+ asyncio.ensure_future(self._connection_ack.wait()),
188
+ asyncio.ensure_future(self._closed.wait()),
189
+ ]
190
+ try:
191
+ done, _ = await asyncio.wait(
192
+ waits,
193
+ timeout=self._account.request_timeout,
194
+ return_when=asyncio.FIRST_COMPLETED,
195
+ )
196
+ finally:
197
+ await cancel_task(*waits)
198
+ if not done:
199
+ self._start_backoff(BACKOFF_NOT_ACKNOWLEDGED)
200
+ if self._ws:
201
+ await self._ws.close()
202
+ return self._connection_ack.is_set()
203
+
107
204
  async def start_subscription(
108
205
  self, payload: dict[str, Any], callback: Callable[[dict[str, Any]], None]
109
206
  ) -> Callable[[], Awaitable[None]] | None:
@@ -121,6 +218,10 @@ class WebSocketMonitor:
121
218
  if self.connected:
122
219
  assert self._ws
123
220
  await self._ws.send_json({"id": _id, "type": "complete"})
221
+ if not self._subscriptions:
222
+ # Don't keep a connection open with nothing on it; the next
223
+ # subscription reconnects
224
+ await self.close()
124
225
 
125
226
  return unsubscribe
126
227
 
@@ -144,6 +245,17 @@ class WebSocketMonitor:
144
245
  """Receive a message from a web socket."""
145
246
  if not (websocket := self._ws):
146
247
  return
248
+ try:
249
+ await self._receive(websocket)
250
+ finally:
251
+ # A receiver restarted on an open connection keeps its ack
252
+ if websocket.closed:
253
+ self._connection_ack.clear()
254
+ self._closed.set()
255
+ self._log_message("web socket stopped")
256
+
257
+ async def _receive(self, websocket: ClientWebSocketResponse[bool]) -> None:
258
+ """Handle messages until the web socket closes."""
147
259
  loop = asyncio.get_running_loop()
148
260
  ack_deadline = loop.time() + self._account.request_timeout
149
261
  while not websocket.closed:
@@ -157,10 +269,11 @@ class WebSocketMonitor:
157
269
  msg = await websocket.receive(timeout=timeout)
158
270
  if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.CLOSED):
159
271
  self._log_message(msg)
160
- if msg.extra == "Unauthenticated":
272
+ self._close_message = msg.extra or None
273
+ if msg.extra == UNAUTHENTICATED:
161
274
  self._disconnect = True
162
275
  elif msg.extra == RATE_LIMITED:
163
- self._backoff_reason = "was rate limited"
276
+ self._start_backoff(BACKOFF_RATE_LIMITED)
164
277
  break
165
278
  self._last_received = datetime.now(UTC)
166
279
  if msg.type == WSMsgType.TEXT:
@@ -168,13 +281,17 @@ class WebSocketMonitor:
168
281
  if (data_type := data.get("type")) == "connection_ack":
169
282
  self._connection_ack.set()
170
283
  elif data_type == "next":
171
- self._rate_limit_attempt = 0
284
+ self._reset_backoff()
172
285
  if (_id := data.get("id")) in self._subscriptions:
173
286
  _fn = self._subscriptions[_id][0]
174
- if inspect.iscoroutinefunction(_fn):
175
- await _fn(data)
176
- else:
177
- _fn(data)
287
+ # A failing callback shouldn't take the connection down
288
+ try:
289
+ if inspect.iscoroutinefunction(_fn):
290
+ await _fn(data)
291
+ else:
292
+ _fn(data)
293
+ except Exception: # pylint: disable=broad-except
294
+ _LOGGER.exception("Error in a subscription callback")
178
295
  else:
179
296
  self._log_message(msg)
180
297
  elif msg.type == WSMsgType.ERROR:
@@ -182,14 +299,12 @@ class WebSocketMonitor:
182
299
  continue
183
300
  except TimeoutError:
184
301
  if not acknowledged:
185
- self._backoff_reason = "was not acknowledged"
302
+ self._start_backoff(BACKOFF_NOT_ACKNOWLEDGED)
186
303
  await websocket.close()
187
304
  break
188
305
  # The connection has stayed open, so any rate limit has passed
189
- self._rate_limit_attempt = 0
306
+ self._reset_backoff()
190
307
  await self._resubscribe_all()
191
- self._connection_ack.clear()
192
- self._log_message("web socket stopped")
193
308
 
194
309
  async def _monitor(self) -> None:
195
310
  """Monitor a web socket connection."""
@@ -200,10 +315,17 @@ class WebSocketMonitor:
200
315
  # Need to restart the receiver
201
316
  self._receiver_task = asyncio.ensure_future(self._receiver())
202
317
  await asyncio.sleep(1)
203
- if self._backoff_reason:
204
- await self._backoff()
205
- if self._disconnect or self.connected:
206
- continue
318
+ if not self._subscriptions:
319
+ # Nothing to resume, so stop rather than keep reconnecting; the
320
+ # next subscription reconnects once any backoff has passed
321
+ self._log_message("web socket monitor stopped, nothing subscribed")
322
+ break
323
+ while delay := self.backoff_remaining:
324
+ # Re-check after waiting, as another monitor for the same session
325
+ # may have pushed the retry back
326
+ await asyncio.sleep(delay)
327
+ if self._disconnect or self.connected:
328
+ continue
207
329
  if not self._disconnect:
208
330
  try:
209
331
  await self.new_connection()
@@ -217,23 +339,35 @@ class WebSocketMonitor:
217
339
  self._log_message("web socket connection reopened")
218
340
  await self._resubscribe_all()
219
341
 
220
- async def _backoff(self) -> None:
221
- """Wait before reconnecting after a rate limit or a missing connection_ack."""
342
+ def _start_backoff(self, reason: str) -> None:
343
+ """Hold off reconnecting after a rate limit or a missing connection_ack.
344
+
345
+ The retry time is recorded right away, so closing the monitor can't lose
346
+ it; a connection backs off at most once.
347
+ """
348
+ if self._backoff_reason:
349
+ return
350
+ self._backoff_reason = reason
351
+ state = self._backoff_state
222
352
  delay = min(
223
- RATE_LIMIT_BACKOFF_MIN * 2**self._rate_limit_attempt
353
+ RATE_LIMIT_BACKOFF_MIN * 2**state.attempt
224
354
  + uniform(0, RATE_LIMIT_BACKOFF_MIN),
225
355
  RATE_LIMIT_BACKOFF_MAX,
226
356
  )
227
- self._rate_limit_attempt += 1
357
+ state.attempt += 1
358
+ # Don't cut short a longer backoff another monitor for the session started
359
+ if (retry_at := time.monotonic() + delay) > state.retry_at:
360
+ state.retry_at = retry_at
361
+ state.rate_limited = reason == BACKOFF_RATE_LIMITED
228
362
  _LOGGER.warning(
229
363
  "Web socket connection %s, reconnecting in %.0f seconds",
230
- self._backoff_reason,
231
- delay,
364
+ reason,
365
+ self.backoff_remaining,
232
366
  )
233
- try:
234
- await asyncio.sleep(delay)
235
- finally:
236
- self._backoff_reason = None
367
+
368
+ def _reset_backoff(self) -> None:
369
+ """Start the next backoff over, now that the connection is healthy."""
370
+ self._backoff_state.attempt = 0
237
371
 
238
372
  async def start_monitor(self) -> None:
239
373
  """Start or restart the monitor task."""
@@ -247,7 +381,6 @@ class WebSocketMonitor:
247
381
  async def close(self) -> None:
248
382
  """Close the web socket."""
249
383
  self._disconnect = True
250
- self._backoff_reason = None
251
384
  if self._ws:
252
385
  await self._ws.close()
253
386
  await cancel_task(self._monitor_task, self._receiver_task)
@@ -1,54 +0,0 @@
1
- # -*- coding: utf-8 -*-
2
- # Generated by the protocol buffer compiler. DO NOT EDIT!
3
- # NO CHECKED-IN PROTOBUF GENCODE
4
- # source: rivian/parallax/proto/departure.proto
5
- # Protobuf Python Version: 6.31.1
6
- """Generated protocol buffer code."""
7
- from google.protobuf import descriptor as _descriptor
8
- from google.protobuf import descriptor_pool as _descriptor_pool
9
- from google.protobuf import runtime_version as _runtime_version
10
- from google.protobuf import symbol_database as _symbol_database
11
- from google.protobuf.internal import builder as _builder
12
- _runtime_version.ValidateProtobufRuntimeVersion(
13
- _runtime_version.Domain.PUBLIC,
14
- 6,
15
- 31,
16
- 1,
17
- '',
18
- 'rivian/parallax/proto/departure.proto'
19
- )
20
- # @@protoc_insertion_point(imports)
21
-
22
- _sym_db = _symbol_database.Default()
23
-
24
-
25
-
26
-
27
- DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n%rivian/parallax/proto/departure.proto\x12\x19rivian.parallax.departure\"\xd2\x0b\n\x12\x44\x65partureSchedules\x12H\n\x08schedule\x18\x01 \x03(\x0b\x32\x36.rivian.parallax.departure.DepartureSchedules.Schedule\x12K\n\nupdated_at\x18\x02 \x01(\x0b\x32\x37.rivian.parallax.departure.DepartureSchedules.UpdatedAt\x1a\xfd\x01\n\x08Schedule\x12\x0f\n\x02id\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x11\n\x04name\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x12\n\nis_enabled\x18\x03 \x01(\x08\x12L\n\noccurrence\x18\x04 \x01(\x0b\x32\x38.rivian.parallax.departure.DepartureSchedules.Occurrence\x12[\n\x12\x64\x65parture_settings\x18\x06 \x01(\x0b\x32?.rivian.parallax.departure.DepartureSchedules.DepartureSettingsB\x05\n\x03_idB\x07\n\x05_name\x1at\n\nOccurrence\x12@\n\x04\x64\x61ys\x18\x01 \x01(\x0b\x32\x32.rivian.parallax.departure.DepartureSchedules.Days\x12\x16\n\tstarts_at\x18\x02 \x01(\x05H\x00\x88\x01\x01\x42\x0c\n\n_starts_at\x1a~\n\x04\x44\x61ys\x12\x0e\n\x06monday\x18\x01 \x01(\x08\x12\x0f\n\x07tuesday\x18\x02 \x01(\x08\x12\x11\n\twednesday\x18\x03 \x01(\x08\x12\x10\n\x08thursday\x18\x04 \x01(\x08\x12\x0e\n\x06\x66riday\x18\x05 \x01(\x08\x12\x10\n\x08saturday\x18\x06 \x01(\x08\x12\x0e\n\x06sunday\x18\x07 \x01(\x08\x1a\x95\x01\n\x11\x44\x65partureSettings\x12W\n\x10\x63omfort_settings\x18\x01 \x01(\x0b\x32=.rivian.parallax.departure.DepartureSchedules.ComfortSettings\x12\'\n\x1fshould_override_charge_schedule\x18\x02 \x01(\x08\x1a\xde\x01\n\x0f\x43omfortSettings\x12\x19\n\x11\x63\x61\x62in_temperature\x18\x01 \x01(\x02\x12I\n\x13\x66ront_defog_defrost\x18\x02 \x01(\x0e\x32,.rivian.parallax.departure.DefogDefrostLevel\x12\x65\n\x18surface_heat_vent_levels\x18\x03 \x01(\x0b\x32\x43.rivian.parallax.departure.DepartureSchedules.SurfaceHeatVentLevels\x1a\x88\x03\n\x15SurfaceHeatVentLevels\x12H\n\x0f\x66ront_left_seat\x18\x01 \x01(\x0e\x32/.rivian.parallax.departure.SurfaceHeatVentLevel\x12I\n\x10\x66ront_right_seat\x18\x02 \x01(\x0e\x32/.rivian.parallax.departure.SurfaceHeatVentLevel\x12G\n\x0erear_left_seat\x18\x03 \x01(\x0e\x32/.rivian.parallax.departure.SurfaceHeatVentLevel\x12H\n\x0frear_right_seat\x18\x04 \x01(\x0e\x32/.rivian.parallax.departure.SurfaceHeatVentLevel\x12G\n\x0esteering_wheel\x18\x05 \x01(\x0e\x32/.rivian.parallax.departure.SurfaceHeatVentLevel\x1a+\n\tUpdatedAt\x12\x0f\n\x07seconds\x18\x01 \x01(\x03\x12\r\n\x05nanos\x18\x02 \x01(\x05*^\n\x11\x44\x65\x66ogDefrostLevel\x12\x15\n\x11\x44\x45\x46OG_DEFROST_OFF\x10\x00\x12\x17\n\x13\x44\x45\x46OG_DEFROST_DEFOG\x10\x01\x12\x19\n\x15\x44\x45\x46OG_DEFROST_DEFROST\x10\x02*\x9f\x01\n\x14SurfaceHeatVentLevel\x12\x0f\n\x0bSURFACE_OFF\x10\x00\x12\x12\n\x0eSURFACE_HEAT_1\x10\x01\x12\x12\n\x0eSURFACE_HEAT_2\x10\x02\x12\x12\n\x0eSURFACE_HEAT_3\x10\x03\x12\x12\n\x0eSURFACE_VENT_1\x10\x04\x12\x12\n\x0eSURFACE_VENT_2\x10\x05\x12\x12\n\x0eSURFACE_VENT_3\x10\x06\x62\x06proto3')
28
-
29
- _globals = globals()
30
- _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
31
- _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'rivian.parallax.proto.departure_pb2', _globals)
32
- if not _descriptor._USE_C_DESCRIPTORS:
33
- DESCRIPTOR._loaded_options = None
34
- _globals['_DEFOGDEFROSTLEVEL']._serialized_start=1561
35
- _globals['_DEFOGDEFROSTLEVEL']._serialized_end=1655
36
- _globals['_SURFACEHEATVENTLEVEL']._serialized_start=1658
37
- _globals['_SURFACEHEATVENTLEVEL']._serialized_end=1817
38
- _globals['_DEPARTURESCHEDULES']._serialized_start=69
39
- _globals['_DEPARTURESCHEDULES']._serialized_end=1559
40
- _globals['_DEPARTURESCHEDULES_SCHEDULE']._serialized_start=243
41
- _globals['_DEPARTURESCHEDULES_SCHEDULE']._serialized_end=496
42
- _globals['_DEPARTURESCHEDULES_OCCURRENCE']._serialized_start=498
43
- _globals['_DEPARTURESCHEDULES_OCCURRENCE']._serialized_end=614
44
- _globals['_DEPARTURESCHEDULES_DAYS']._serialized_start=616
45
- _globals['_DEPARTURESCHEDULES_DAYS']._serialized_end=742
46
- _globals['_DEPARTURESCHEDULES_DEPARTURESETTINGS']._serialized_start=745
47
- _globals['_DEPARTURESCHEDULES_DEPARTURESETTINGS']._serialized_end=894
48
- _globals['_DEPARTURESCHEDULES_COMFORTSETTINGS']._serialized_start=897
49
- _globals['_DEPARTURESCHEDULES_COMFORTSETTINGS']._serialized_end=1119
50
- _globals['_DEPARTURESCHEDULES_SURFACEHEATVENTLEVELS']._serialized_start=1122
51
- _globals['_DEPARTURESCHEDULES_SURFACEHEATVENTLEVELS']._serialized_end=1514
52
- _globals['_DEPARTURESCHEDULES_UPDATEDAT']._serialized_start=1516
53
- _globals['_DEPARTURESCHEDULES_UPDATEDAT']._serialized_end=1559
54
- # @@protoc_insertion_point(module_scope)