rivian-python-client 2.0.0__tar.gz → 2.1.0__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.
- rivian_python_client-2.1.0/.gitignore +34 -0
- rivian_python_client-2.1.0/PKG-INFO +42 -0
- rivian_python_client-2.1.0/README.md +26 -0
- rivian_python_client-2.1.0/pyproject.toml +66 -0
- {rivian_python_client-2.0.0 → rivian_python_client-2.1.0}/src/rivian/__init__.py +2 -1
- rivian_python_client-2.1.0/src/rivian/__version__.py +4 -0
- {rivian_python_client-2.0.0 → rivian_python_client-2.1.0}/src/rivian/ble.py +5 -5
- rivian_python_client-2.1.0/src/rivian/parallax.py +628 -0
- {rivian_python_client-2.0.0 → rivian_python_client-2.1.0}/src/rivian/rivian.py +118 -55
- {rivian_python_client-2.0.0 → rivian_python_client-2.1.0}/src/rivian/ws_monitor.py +8 -5
- rivian_python_client-2.0.0/PKG-INFO +0 -49
- rivian_python_client-2.0.0/README.md +0 -26
- rivian_python_client-2.0.0/pyproject.toml +0 -53
- {rivian_python_client-2.0.0 → rivian_python_client-2.1.0}/src/rivian/const.py +0 -0
- {rivian_python_client-2.0.0 → rivian_python_client-2.1.0}/src/rivian/exceptions.py +0 -0
- {rivian_python_client-2.0.0 → rivian_python_client-2.1.0}/src/rivian/py.typed +0 -0
- {rivian_python_client-2.0.0 → rivian_python_client-2.1.0}/src/rivian/schemas/charging.graphql +0 -0
- {rivian_python_client-2.0.0 → rivian_python_client-2.1.0}/src/rivian/schemas/gateway.graphql +0 -0
- {rivian_python_client-2.0.0 → rivian_python_client-2.1.0}/src/rivian/schemas/orders.graphql +0 -0
- {rivian_python_client-2.0.0 → rivian_python_client-2.1.0}/src/rivian/utils.py +0 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Version file (auto-generated by Hatchling)
|
|
2
|
+
src/rivian/__version__.py
|
|
3
|
+
|
|
4
|
+
# IDE generated files
|
|
5
|
+
.vscode/
|
|
6
|
+
|
|
7
|
+
# Python generated files
|
|
8
|
+
__pycache__/
|
|
9
|
+
*.pyc
|
|
10
|
+
.pytest_cache
|
|
11
|
+
|
|
12
|
+
# Packaging generated files
|
|
13
|
+
*.egg-info
|
|
14
|
+
|
|
15
|
+
# Tox
|
|
16
|
+
.tox
|
|
17
|
+
|
|
18
|
+
# Code coverage
|
|
19
|
+
.coverage
|
|
20
|
+
|
|
21
|
+
# JS files
|
|
22
|
+
*/node_modules/
|
|
23
|
+
*package-lock.json
|
|
24
|
+
|
|
25
|
+
dist/
|
|
26
|
+
|
|
27
|
+
# Environments
|
|
28
|
+
.env
|
|
29
|
+
.venv
|
|
30
|
+
env/
|
|
31
|
+
venv/
|
|
32
|
+
ENV/
|
|
33
|
+
env.bak/
|
|
34
|
+
venv.bak/
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rivian-python-client
|
|
3
|
+
Version: 2.1.0
|
|
4
|
+
Summary: Rivian API Client (Unofficial)
|
|
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
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: aiohttp>=3.0.0
|
|
9
|
+
Requires-Dist: backports-strenum>=1.2.4; python_version < '3.11'
|
|
10
|
+
Requires-Dist: cryptography>=41.0.1
|
|
11
|
+
Requires-Dist: typing-extensions>=4.14.1; python_version < '3.11'
|
|
12
|
+
Provides-Extra: ble
|
|
13
|
+
Requires-Dist: bleak>=0.21; extra == 'ble'
|
|
14
|
+
Requires-Dist: dbus-fast>=2.11.0; (sys_platform == 'linux') and extra == 'ble'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Python: Rivian API Client
|
|
18
|
+
|
|
19
|
+
Currently a Work In Progress
|
|
20
|
+
|
|
21
|
+
## Dependencies
|
|
22
|
+
|
|
23
|
+
[uv](https://docs.astral.sh/uv/)
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Setup
|
|
30
|
+
|
|
31
|
+
Install project dependencies into the uv virtual environment and run pre-commit
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
uv sync --all-extras
|
|
35
|
+
pre-commit install
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Run Tests
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
uv run pytest
|
|
42
|
+
```
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Python: Rivian API Client
|
|
2
|
+
|
|
3
|
+
Currently a Work In Progress
|
|
4
|
+
|
|
5
|
+
## Dependencies
|
|
6
|
+
|
|
7
|
+
[uv](https://docs.astral.sh/uv/)
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
Install project dependencies into the uv virtual environment and run pre-commit
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
uv sync --all-extras
|
|
19
|
+
pre-commit install
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Run Tests
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
uv run pytest
|
|
26
|
+
```
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "rivian-python-client"
|
|
3
|
+
description = "Rivian API Client (Unofficial)"
|
|
4
|
+
authors = [
|
|
5
|
+
{ name = "Brian Retterer", email = "bretterer@gmail.com" },
|
|
6
|
+
{ name = "J.R. Gutierrez", email = "jrgutier@gmail.com" },
|
|
7
|
+
{ name = "Nathan Spencer", email = "natekspencer@gmail.com" },
|
|
8
|
+
{ name = "Trevor Mack", email = "drummer8001@gmail.com" },
|
|
9
|
+
]
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
license = "MIT"
|
|
13
|
+
dependencies = [
|
|
14
|
+
"aiohttp (>=3.0.0)",
|
|
15
|
+
"cryptography (>=41.0.1)",
|
|
16
|
+
"backports-strenum (>=1.2.4) ; python_version < '3.11'",
|
|
17
|
+
"typing_extensions (>=4.14.1) ; python_version < '3.11'",
|
|
18
|
+
]
|
|
19
|
+
dynamic = ["version"]
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
ble = [
|
|
23
|
+
"bleak (>=0.21)",
|
|
24
|
+
"dbus-fast (>=2.11.0) ; sys_platform == 'linux'",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = [
|
|
29
|
+
"pytest>=9.0,<10.0",
|
|
30
|
+
"pytest-asyncio>=1.0,<1.5",
|
|
31
|
+
"python-dotenv>=1.0,<1.3",
|
|
32
|
+
"aresponses>=3.0,<4.0",
|
|
33
|
+
"mypy>=1.7,<3.0",
|
|
34
|
+
"ruff>=0.16,<0.17",
|
|
35
|
+
"pre-commit>=4.6.1,<5",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.hooks.version]
|
|
39
|
+
path = "src/rivian/__version__.py"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.sdist]
|
|
42
|
+
include = ["src/rivian"]
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.wheel]
|
|
45
|
+
include = ["src/rivian"]
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.wheel.sources]
|
|
48
|
+
"src/rivian" = "rivian"
|
|
49
|
+
|
|
50
|
+
[tool.hatch.version]
|
|
51
|
+
source = "uv-dynamic-versioning"
|
|
52
|
+
|
|
53
|
+
[tool.uv]
|
|
54
|
+
default-groups = "all"
|
|
55
|
+
cache-keys = [{ file = "pyproject.toml" }, { git = { commit = true, tags = true }}]
|
|
56
|
+
|
|
57
|
+
[tool.uv-dynamic-versioning]
|
|
58
|
+
pattern = "default-unprefixed"
|
|
59
|
+
|
|
60
|
+
[tool.ruff.lint.isort]
|
|
61
|
+
combine-as-imports = true
|
|
62
|
+
split-on-trailing-comma = false
|
|
63
|
+
|
|
64
|
+
[build-system]
|
|
65
|
+
requires = ["hatchling", "uv-dynamic-versioning"]
|
|
66
|
+
build-backend = "hatchling.build"
|
|
@@ -33,7 +33,7 @@ class BleNotificationResponse:
|
|
|
33
33
|
|
|
34
34
|
def __init__(self) -> None:
|
|
35
35
|
"""Initialize the BLE notification response helper."""
|
|
36
|
-
self.data:
|
|
36
|
+
self.data: bytearray | None = None
|
|
37
37
|
self.event = asyncio.Event()
|
|
38
38
|
|
|
39
39
|
def notification_handler(self, _, notification_data: bytearray) -> None:
|
|
@@ -115,7 +115,7 @@ async def pair_phone(
|
|
|
115
115
|
|
|
116
116
|
_LOGGER.debug("Successfully paired with %s", device)
|
|
117
117
|
return True
|
|
118
|
-
except Exception as ex: # pylint: disable=broad-except
|
|
118
|
+
except Exception as ex: # pylint: disable=broad-except # noqa: BLE001
|
|
119
119
|
_LOGGER.debug(
|
|
120
120
|
"Couldn't connect to %s. "
|
|
121
121
|
'Make sure you are in the correct vehicle and have selected "Set Up" for the appropriate key and try again'
|
|
@@ -157,10 +157,10 @@ async def set_bluez_pairable(device: BLEDevice) -> bool:
|
|
|
157
157
|
introspection = await bus.introspect("org.bluez", path)
|
|
158
158
|
pobject = bus.get_proxy_object("org.bluez", path, introspection)
|
|
159
159
|
iface = pobject.get_interface("org.bluez.Adapter1")
|
|
160
|
-
if not await iface.get_pairable():
|
|
161
|
-
await iface.set_pairable(True)
|
|
160
|
+
if not await iface.get_pairable(): # type: ignore[attr-defined]
|
|
161
|
+
await iface.set_pairable(True) # type: ignore[attr-defined]
|
|
162
162
|
bus.disconnect()
|
|
163
|
-
except Exception as ex: # pylint: disable=broad-except
|
|
163
|
+
except Exception as ex: # pylint: disable=broad-except # noqa: BLE001
|
|
164
164
|
_LOGGER.error(ex)
|
|
165
165
|
return False
|
|
166
166
|
|
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
"""Decoder for Rivian Parallax protobuf payloads.
|
|
2
|
+
|
|
3
|
+
Decodes base64-encoded protobuf binary payloads from the Parallax WebSocket
|
|
4
|
+
subscription into structured Python dicts.
|
|
5
|
+
|
|
6
|
+
Reference: https://github.com/kaedenbrinkman/rivian-api (RivDocs)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import logging
|
|
13
|
+
import struct
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
_LOGGER = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
CLOSURE_MAP = {
|
|
21
|
+
1: "doorFrontLeftClosed",
|
|
22
|
+
2: "doorFrontRightClosed",
|
|
23
|
+
3: "doorRearLeftClosed",
|
|
24
|
+
4: "doorRearRightClosed",
|
|
25
|
+
5: "closureFrunkClosed",
|
|
26
|
+
6: "closureSideBinLeftClosed",
|
|
27
|
+
7: "closureLiftgateClosed",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
LOCK_MAP = {
|
|
31
|
+
1: "doorFrontLeftLocked",
|
|
32
|
+
2: "doorFrontRightLocked",
|
|
33
|
+
3: "doorRearLeftLocked",
|
|
34
|
+
4: "doorRearRightLocked",
|
|
35
|
+
5: "closureFrunkLocked",
|
|
36
|
+
7: "closureLiftgateLocked",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
POWER_STATE_MAP = {
|
|
40
|
+
1: "sleep",
|
|
41
|
+
2: "standby",
|
|
42
|
+
3: "ready",
|
|
43
|
+
4: "go",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
TIRE_POSITION_MAP = {
|
|
47
|
+
1: "FrontLeft",
|
|
48
|
+
2: "FrontRight",
|
|
49
|
+
3: "RearLeft",
|
|
50
|
+
4: "RearRight",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _decode_varint(data: bytes, offset: int) -> tuple[int, int]:
|
|
55
|
+
"""Decode a protobuf varint, return (value, new_offset)."""
|
|
56
|
+
result = 0
|
|
57
|
+
shift = 0
|
|
58
|
+
while offset < len(data):
|
|
59
|
+
byte = data[offset]
|
|
60
|
+
result |= (byte & 0x7F) << shift
|
|
61
|
+
shift += 7
|
|
62
|
+
offset += 1
|
|
63
|
+
if not (byte & 0x80):
|
|
64
|
+
break
|
|
65
|
+
return result, offset
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _decode_protobuf_fields(data: bytes) -> list[tuple[int, int, Any]]:
|
|
69
|
+
"""Decode raw protobuf bytes into a list of (field_number, wire_type, value) tuples."""
|
|
70
|
+
fields = []
|
|
71
|
+
i = 0
|
|
72
|
+
while i < len(data):
|
|
73
|
+
tag, i = _decode_varint(data, i)
|
|
74
|
+
field_num = tag >> 3
|
|
75
|
+
wire_type = tag & 0x07
|
|
76
|
+
value: Any
|
|
77
|
+
|
|
78
|
+
if wire_type == 0: # Varint
|
|
79
|
+
value, i = _decode_varint(data, i)
|
|
80
|
+
fields.append((field_num, wire_type, value))
|
|
81
|
+
elif wire_type == 1: # 64-bit float
|
|
82
|
+
if i + 8 <= len(data):
|
|
83
|
+
value = struct.unpack("<d", data[i : i + 8])[0]
|
|
84
|
+
i += 8
|
|
85
|
+
fields.append((field_num, wire_type, value))
|
|
86
|
+
else:
|
|
87
|
+
break
|
|
88
|
+
elif wire_type == 2: # Length-delimited
|
|
89
|
+
length, i = _decode_varint(data, i)
|
|
90
|
+
if i + length <= len(data):
|
|
91
|
+
value = data[i : i + length]
|
|
92
|
+
i += length
|
|
93
|
+
fields.append((field_num, wire_type, value))
|
|
94
|
+
else:
|
|
95
|
+
break
|
|
96
|
+
elif wire_type == 5: # 32-bit float
|
|
97
|
+
if i + 4 <= len(data):
|
|
98
|
+
value = struct.unpack("<f", data[i : i + 4])[0]
|
|
99
|
+
i += 4
|
|
100
|
+
fields.append((field_num, wire_type, value))
|
|
101
|
+
else:
|
|
102
|
+
break
|
|
103
|
+
else:
|
|
104
|
+
_LOGGER.debug("Unknown wire type %d for field %d", wire_type, field_num)
|
|
105
|
+
break
|
|
106
|
+
|
|
107
|
+
return fields
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def decode_battery_state(payload: str) -> dict[str, Any]:
|
|
111
|
+
"""Decode energy.high_voltage.battery_state.
|
|
112
|
+
|
|
113
|
+
Returns dict with keys:
|
|
114
|
+
- soc: float (percentage, 0-100)
|
|
115
|
+
- packEnergyKwh: float
|
|
116
|
+
- rangeKm: float (if present)
|
|
117
|
+
"""
|
|
118
|
+
if not payload:
|
|
119
|
+
return {}
|
|
120
|
+
try:
|
|
121
|
+
data = base64.b64decode(payload)
|
|
122
|
+
fields = _decode_protobuf_fields(data)
|
|
123
|
+
result: dict[str, Any] = {}
|
|
124
|
+
|
|
125
|
+
for field_num, wire_type, value in fields:
|
|
126
|
+
if field_num == 1 and wire_type == 2:
|
|
127
|
+
# Nested charge_state message
|
|
128
|
+
inner_fields = _decode_protobuf_fields(value)
|
|
129
|
+
for inner_num, inner_wt, inner_val in inner_fields:
|
|
130
|
+
if inner_num == 1 and inner_wt == 1: # soc (float)
|
|
131
|
+
result["soc"] = round(inner_val, 2)
|
|
132
|
+
elif inner_num == 2 and inner_wt == 1: # packEnergyKwh (float)
|
|
133
|
+
result["packEnergyKwh"] = round(inner_val, 2)
|
|
134
|
+
elif inner_num == 3 and inner_wt == 5: # rangeKm (float)
|
|
135
|
+
result["rangeKm"] = round(inner_val, 1)
|
|
136
|
+
|
|
137
|
+
return result
|
|
138
|
+
except Exception:
|
|
139
|
+
_LOGGER.debug("Failed to decode battery_state payload", exc_info=True)
|
|
140
|
+
return {}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def decode_cabin_temperatures(payload: str) -> dict[str, Any]:
|
|
144
|
+
"""Decode comfort.cabin.cabin_temperatures.
|
|
145
|
+
|
|
146
|
+
Returns dict with keys:
|
|
147
|
+
- cabinClimateInteriorTemperature: float (Celsius)
|
|
148
|
+
- cabinClimateDriverTemperature: float (Celsius)
|
|
149
|
+
"""
|
|
150
|
+
if not payload:
|
|
151
|
+
return {}
|
|
152
|
+
try:
|
|
153
|
+
data = base64.b64decode(payload)
|
|
154
|
+
fields = _decode_protobuf_fields(data)
|
|
155
|
+
result: dict[str, Any] = {}
|
|
156
|
+
|
|
157
|
+
for field_num, wire_type, value in fields:
|
|
158
|
+
if field_num == 3 and wire_type == 5: # interior temp (float, Celsius)
|
|
159
|
+
result["cabinClimateInteriorTemperature"] = round(value, 1)
|
|
160
|
+
if field_num == 4 and wire_type == 5: # interior temp (float, Celsius)
|
|
161
|
+
result["cabinClimateDriverTemperature"] = round(value, 1)
|
|
162
|
+
|
|
163
|
+
return result
|
|
164
|
+
except Exception:
|
|
165
|
+
_LOGGER.debug("Failed to decode cabin temperatures payload", exc_info=True)
|
|
166
|
+
return {}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def decode_charge_session_breakdown(payload: str) -> dict[str, Any]:
|
|
170
|
+
"""Decode energy_edge_compute.graphs.charge_session_breakdown.
|
|
171
|
+
|
|
172
|
+
Returns dict with keys matching legacy getLiveSessionData field names:
|
|
173
|
+
- totalChargedEnergy: float (kWh total)
|
|
174
|
+
- power: float (kW, current charge rate)
|
|
175
|
+
- timeElapsed: int (seconds, estimated)
|
|
176
|
+
- rangeAddedThisSession: float (km, estimated from energy)
|
|
177
|
+
"""
|
|
178
|
+
if not payload:
|
|
179
|
+
return {}
|
|
180
|
+
try:
|
|
181
|
+
data = base64.b64decode(payload)
|
|
182
|
+
fields = _decode_protobuf_fields(data)
|
|
183
|
+
result: dict[str, Any] = {}
|
|
184
|
+
|
|
185
|
+
total_kwh = 0.0
|
|
186
|
+
|
|
187
|
+
for field_num, wire_type, value in fields:
|
|
188
|
+
if field_num == 1 and wire_type == 5: # totalKwh (float)
|
|
189
|
+
total_kwh = round(value, 4)
|
|
190
|
+
result["totalChargedEnergy"] = total_kwh
|
|
191
|
+
elif field_num == 9 and wire_type == 5: # currentPower (float, kW)
|
|
192
|
+
result["power"] = round(value, 2)
|
|
193
|
+
elif field_num == 7 and wire_type == 0: # timeRemainingMins or elapsed secs
|
|
194
|
+
result["_time_field_7"] = value
|
|
195
|
+
elif field_num == 10 and wire_type == 0: # charge power integer (kW)
|
|
196
|
+
if "power" not in result:
|
|
197
|
+
result["power"] = float(value)
|
|
198
|
+
elif field_num == 13 and wire_type == 0: # chargingState enum
|
|
199
|
+
result["_charging_state"] = value
|
|
200
|
+
|
|
201
|
+
# Estimate range added: ~3.5 km/kWh (~2.17 mi/kWh) is a typical Rivian average
|
|
202
|
+
if total_kwh > 0:
|
|
203
|
+
result["rangeAddedThisSession"] = round(total_kwh * 3.5, 1)
|
|
204
|
+
|
|
205
|
+
# Derive charge rate (km/h) from current power (kW)
|
|
206
|
+
if "power" in result:
|
|
207
|
+
p = result["power"]
|
|
208
|
+
result["kilometersChargedPerHour"] = round(p * 3.5, 1) if p > 0 else 0.0
|
|
209
|
+
|
|
210
|
+
return result
|
|
211
|
+
except Exception:
|
|
212
|
+
_LOGGER.debug(
|
|
213
|
+
"Failed to decode charge_session_breakdown payload", exc_info=True
|
|
214
|
+
)
|
|
215
|
+
return {}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def decode_charging_graph_global(payload: str) -> dict[str, Any]:
|
|
219
|
+
"""Decode energy_edge_compute.graphs.charging_graph_global.
|
|
220
|
+
|
|
221
|
+
Returns dict with keys:
|
|
222
|
+
- startTime: str (ISO format timestamp of session start)
|
|
223
|
+
- timeElapsed: int (seconds elapsed since session start)
|
|
224
|
+
- power: float (kW, latest segment power)
|
|
225
|
+
"""
|
|
226
|
+
if not payload:
|
|
227
|
+
return {}
|
|
228
|
+
try:
|
|
229
|
+
data = base64.b64decode(payload)
|
|
230
|
+
outer = _decode_protobuf_fields(data)
|
|
231
|
+
segments = []
|
|
232
|
+
for field_num, wire_type, value in outer:
|
|
233
|
+
if field_num == 1 and wire_type == 2:
|
|
234
|
+
inner = _decode_protobuf_fields(value)
|
|
235
|
+
seg: dict[str, Any] = {}
|
|
236
|
+
for in_num, in_wt, in_val in inner:
|
|
237
|
+
if in_num == 1 and in_wt == 0:
|
|
238
|
+
seg["soc"] = in_val
|
|
239
|
+
elif in_num == 2 and in_wt == 5:
|
|
240
|
+
seg["power"] = round(in_val, 2)
|
|
241
|
+
elif in_num == 3 and in_wt == 0:
|
|
242
|
+
seg["start_ms"] = in_val
|
|
243
|
+
elif in_num == 4 and in_wt == 0:
|
|
244
|
+
seg["end_ms"] = in_val
|
|
245
|
+
elif in_num == 6 and in_wt == 0:
|
|
246
|
+
seg["state"] = in_val
|
|
247
|
+
segments.append(seg)
|
|
248
|
+
|
|
249
|
+
if not segments:
|
|
250
|
+
return {}
|
|
251
|
+
|
|
252
|
+
active_segments = [
|
|
253
|
+
s for s in segments if s.get("power", 0) > 0 or s.get("state") == 3
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
first_seg = active_segments[0] if active_segments else segments[0]
|
|
257
|
+
result: dict[str, Any] = {}
|
|
258
|
+
|
|
259
|
+
if "start_ms" in first_seg:
|
|
260
|
+
st = datetime.fromtimestamp(first_seg["start_ms"] / 1000, timezone.utc)
|
|
261
|
+
result["startTime"] = st.strftime("%Y-%m-%dT%H:%M:%S.%f%z")
|
|
262
|
+
|
|
263
|
+
if active_segments:
|
|
264
|
+
result["timeElapsed"] = sum(
|
|
265
|
+
max(0, int((s["end_ms"] - s["start_ms"]) / 1000))
|
|
266
|
+
for s in active_segments
|
|
267
|
+
if "end_ms" in s and "start_ms" in s
|
|
268
|
+
)
|
|
269
|
+
else:
|
|
270
|
+
result["timeElapsed"] = 0
|
|
271
|
+
|
|
272
|
+
latest_segment = segments[-1]
|
|
273
|
+
if (
|
|
274
|
+
"power" in latest_segment
|
|
275
|
+
and latest_segment.get("power", 0) > 0
|
|
276
|
+
and latest_segment.get("state") != 8
|
|
277
|
+
):
|
|
278
|
+
result["power"] = latest_segment["power"]
|
|
279
|
+
result["kilometersChargedPerHour"] = round(result["power"] * 3.5, 1)
|
|
280
|
+
else:
|
|
281
|
+
result["power"] = 0.0
|
|
282
|
+
result["kilometersChargedPerHour"] = 0.0
|
|
283
|
+
|
|
284
|
+
return result
|
|
285
|
+
except Exception:
|
|
286
|
+
_LOGGER.debug("Failed to decode charging_graph_global payload", exc_info=True)
|
|
287
|
+
return {}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def decode_charging_session_status(payload: str) -> dict[str, Any]:
|
|
291
|
+
"""Decode charging.session.status.
|
|
292
|
+
|
|
293
|
+
Returns dict with keys:
|
|
294
|
+
- plugConnectionStatus: int (enum)
|
|
295
|
+
- displayStatus: int (enum)
|
|
296
|
+
- evseType: int (enum)
|
|
297
|
+
"""
|
|
298
|
+
if not payload:
|
|
299
|
+
return {}
|
|
300
|
+
try:
|
|
301
|
+
data = base64.b64decode(payload)
|
|
302
|
+
fields = _decode_protobuf_fields(data)
|
|
303
|
+
result: dict[str, Any] = {}
|
|
304
|
+
|
|
305
|
+
for field_num, wire_type, value in fields:
|
|
306
|
+
if field_num == 1 and wire_type == 0:
|
|
307
|
+
result["plugConnectionStatus"] = value
|
|
308
|
+
elif field_num == 2 and wire_type == 0:
|
|
309
|
+
result["displayStatus"] = value
|
|
310
|
+
elif field_num == 3 and wire_type == 0:
|
|
311
|
+
result["evseType"] = value
|
|
312
|
+
|
|
313
|
+
return result
|
|
314
|
+
except Exception:
|
|
315
|
+
_LOGGER.debug("Failed to decode charging.session.status payload", exc_info=True)
|
|
316
|
+
return {}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def decode_closures(payload: str) -> dict[str, Any]:
|
|
320
|
+
"""Decode body.closures.states.
|
|
321
|
+
|
|
322
|
+
Returns dict with keys:
|
|
323
|
+
- doorFrontLeftClosed, doorFrontRightClosed, closureFrunkClosed, etc. ("closed" / "open")
|
|
324
|
+
"""
|
|
325
|
+
if not payload:
|
|
326
|
+
return {}
|
|
327
|
+
try:
|
|
328
|
+
data = base64.b64decode(payload)
|
|
329
|
+
fields = _decode_protobuf_fields(data)
|
|
330
|
+
result: dict[str, Any] = {}
|
|
331
|
+
|
|
332
|
+
for field_num, wire_type, value in fields:
|
|
333
|
+
if field_num == 1 and wire_type == 2: # Repeated nested closure state
|
|
334
|
+
inner = _decode_protobuf_fields(value)
|
|
335
|
+
cid = None
|
|
336
|
+
state_val = None
|
|
337
|
+
for in_num, in_type, in_val in inner:
|
|
338
|
+
if in_num == 1 and in_type == 0:
|
|
339
|
+
cid = in_val
|
|
340
|
+
elif in_num == 2 and in_type == 0:
|
|
341
|
+
state_val = in_val
|
|
342
|
+
|
|
343
|
+
if cid and cid in CLOSURE_MAP and state_val is not None:
|
|
344
|
+
# 1 = open, 2 = closed
|
|
345
|
+
result[CLOSURE_MAP[cid]] = "closed" if state_val == 2 else "open"
|
|
346
|
+
|
|
347
|
+
return result
|
|
348
|
+
except Exception:
|
|
349
|
+
_LOGGER.debug("Failed to decode closures payload", exc_info=True)
|
|
350
|
+
return {}
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def decode_defrost(payload: str) -> dict[str, Any]:
|
|
354
|
+
"""Decode comfort.cabin.defrost_defog_status.
|
|
355
|
+
|
|
356
|
+
Returns dict with keys:
|
|
357
|
+
- defrostDefogStatus: str ("Defrost", "Off")
|
|
358
|
+
"""
|
|
359
|
+
if not payload:
|
|
360
|
+
return {}
|
|
361
|
+
try:
|
|
362
|
+
data = base64.b64decode(payload)
|
|
363
|
+
fields = _decode_protobuf_fields(data)
|
|
364
|
+
result: dict[str, Any] = {}
|
|
365
|
+
for field_num, wire_type, value in fields:
|
|
366
|
+
if field_num == 1 and wire_type == 0:
|
|
367
|
+
result["defrostDefogStatus"] = "Defrost" if value == 2 else "Off"
|
|
368
|
+
return result
|
|
369
|
+
except Exception:
|
|
370
|
+
_LOGGER.debug("Failed to decode defrost payload", exc_info=True)
|
|
371
|
+
return {}
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def decode_gnss(payload: str) -> dict[str, Any]:
|
|
375
|
+
"""Decode dynamics.vehicle.gnss.
|
|
376
|
+
|
|
377
|
+
Returns dict with keys:
|
|
378
|
+
- gnssLocation: {"latitude": float, "longitude": float, "timeStamp": str}
|
|
379
|
+
- gnssAltitude: float (meters)
|
|
380
|
+
"""
|
|
381
|
+
if not payload:
|
|
382
|
+
return {}
|
|
383
|
+
try:
|
|
384
|
+
data = base64.b64decode(payload)
|
|
385
|
+
fields = _decode_protobuf_fields(data)
|
|
386
|
+
lat = None
|
|
387
|
+
lon = None
|
|
388
|
+
alt = None
|
|
389
|
+
for field_num, wire_type, value in fields:
|
|
390
|
+
if field_num == 1 and wire_type == 1: # latitude (float)
|
|
391
|
+
lat = round(value, 6)
|
|
392
|
+
elif field_num == 2 and wire_type == 1: # longitude (float)
|
|
393
|
+
lon = round(value, 6)
|
|
394
|
+
elif field_num == 3 and wire_type == 1: # altitude (float)
|
|
395
|
+
alt = round(value, 1)
|
|
396
|
+
|
|
397
|
+
result: dict[str, Any] = {}
|
|
398
|
+
if lat is not None and lon is not None:
|
|
399
|
+
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f%z")
|
|
400
|
+
result["gnssLocation"] = {
|
|
401
|
+
"latitude": lat,
|
|
402
|
+
"longitude": lon,
|
|
403
|
+
"timeStamp": now_iso,
|
|
404
|
+
}
|
|
405
|
+
if alt is not None:
|
|
406
|
+
result["gnssAltitude"] = alt
|
|
407
|
+
return result
|
|
408
|
+
except Exception:
|
|
409
|
+
_LOGGER.debug("Failed to decode dynamics.vehicle.gnss payload", exc_info=True)
|
|
410
|
+
return {}
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def decode_locks(payload: str) -> dict[str, Any]:
|
|
414
|
+
"""Decode body.locks.states.
|
|
415
|
+
|
|
416
|
+
Returns dict with keys:
|
|
417
|
+
- doorFrontLeftLocked, closureFrunkLocked, etc. ("locked" / "unlocked")
|
|
418
|
+
"""
|
|
419
|
+
if not payload:
|
|
420
|
+
return {}
|
|
421
|
+
try:
|
|
422
|
+
data = base64.b64decode(payload)
|
|
423
|
+
fields = _decode_protobuf_fields(data)
|
|
424
|
+
result: dict[str, Any] = {}
|
|
425
|
+
|
|
426
|
+
for field_num, wire_type, value in fields:
|
|
427
|
+
if field_num == 1 and wire_type == 2: # Repeated nested lock state
|
|
428
|
+
inner = _decode_protobuf_fields(value)
|
|
429
|
+
lid = None
|
|
430
|
+
state_val = None
|
|
431
|
+
for in_num, in_type, in_val in inner:
|
|
432
|
+
if in_num == 1 and in_type == 0:
|
|
433
|
+
lid = in_val
|
|
434
|
+
elif in_num == 2 and in_type == 0:
|
|
435
|
+
state_val = in_val
|
|
436
|
+
|
|
437
|
+
if lid and lid in LOCK_MAP and state_val is not None:
|
|
438
|
+
# 1 = locked, 2 = unlocked
|
|
439
|
+
result[LOCK_MAP[lid]] = "locked" if state_val == 1 else "unlocked"
|
|
440
|
+
|
|
441
|
+
return result
|
|
442
|
+
except Exception:
|
|
443
|
+
_LOGGER.debug("Failed to decode locks payload", exc_info=True)
|
|
444
|
+
return {}
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def decode_odometer(payload: str) -> dict[str, Any]:
|
|
448
|
+
"""Decode dynamics.vehicle.odometer.
|
|
449
|
+
|
|
450
|
+
Returns dict with keys:
|
|
451
|
+
- vehicleMileage: float (meters)
|
|
452
|
+
"""
|
|
453
|
+
if not payload:
|
|
454
|
+
return {}
|
|
455
|
+
try:
|
|
456
|
+
data = base64.b64decode(payload)
|
|
457
|
+
fields = _decode_protobuf_fields(data)
|
|
458
|
+
result: dict[str, Any] = {}
|
|
459
|
+
|
|
460
|
+
for field_num, wire_type, value in fields:
|
|
461
|
+
if field_num == 1 and wire_type == 0:
|
|
462
|
+
# Value is distance in km; HA expects meters
|
|
463
|
+
result["vehicleMileage"] = value * 1000
|
|
464
|
+
|
|
465
|
+
return result
|
|
466
|
+
except Exception:
|
|
467
|
+
_LOGGER.debug("Failed to decode odometer payload", exc_info=True)
|
|
468
|
+
return {}
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def decode_power_state(payload: str) -> dict[str, Any]:
|
|
472
|
+
"""Decode vehicle.power.state.
|
|
473
|
+
|
|
474
|
+
Returns dict with keys:
|
|
475
|
+
- powerState: str ("sleep", "standby", "ready", "go")
|
|
476
|
+
"""
|
|
477
|
+
if not payload:
|
|
478
|
+
return {}
|
|
479
|
+
try:
|
|
480
|
+
data = base64.b64decode(payload)
|
|
481
|
+
fields = _decode_protobuf_fields(data)
|
|
482
|
+
result: dict[str, Any] = {}
|
|
483
|
+
|
|
484
|
+
for field_num, wire_type, value in fields:
|
|
485
|
+
if field_num == 1 and wire_type == 0:
|
|
486
|
+
result["powerState"] = POWER_STATE_MAP.get(value, "standby")
|
|
487
|
+
|
|
488
|
+
return result
|
|
489
|
+
except Exception:
|
|
490
|
+
_LOGGER.debug("Failed to decode power state payload", exc_info=True)
|
|
491
|
+
return {}
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def decode_preconditioning(payload: str) -> dict[str, Any]:
|
|
495
|
+
"""Decode comfort.cabin.cabin_preconditioning_status.
|
|
496
|
+
|
|
497
|
+
Returns dict with keys:
|
|
498
|
+
- cabinPreconditioningStatus: str ("active", "initiate", "off")
|
|
499
|
+
"""
|
|
500
|
+
if not payload:
|
|
501
|
+
return {"cabinPreconditioningStatus": "off"}
|
|
502
|
+
try:
|
|
503
|
+
data = base64.b64decode(payload)
|
|
504
|
+
fields = _decode_protobuf_fields(data)
|
|
505
|
+
status_val = None
|
|
506
|
+
for field_num, wire_type, value in fields:
|
|
507
|
+
if field_num == 1 and wire_type == 0:
|
|
508
|
+
status_val = value
|
|
509
|
+
|
|
510
|
+
if status_val == 4:
|
|
511
|
+
return {"cabinPreconditioningStatus": "active"}
|
|
512
|
+
elif status_val in (1, 2):
|
|
513
|
+
return {"cabinPreconditioningStatus": "initiate"}
|
|
514
|
+
return {"cabinPreconditioningStatus": "off"}
|
|
515
|
+
except Exception:
|
|
516
|
+
_LOGGER.debug("Failed to decode preconditioning payload", exc_info=True)
|
|
517
|
+
return {}
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def decode_time_estimation(payload: str) -> dict[str, Any]:
|
|
521
|
+
"""Decode charging.session.time_estimation.
|
|
522
|
+
|
|
523
|
+
Returns dict with keys:
|
|
524
|
+
- timeToEndOfCharge: int (seconds remaining)
|
|
525
|
+
"""
|
|
526
|
+
if not payload:
|
|
527
|
+
return {}
|
|
528
|
+
try:
|
|
529
|
+
data = base64.b64decode(payload)
|
|
530
|
+
fields = _decode_protobuf_fields(data)
|
|
531
|
+
result: dict[str, Any] = {}
|
|
532
|
+
|
|
533
|
+
for field_num, wire_type, value in fields:
|
|
534
|
+
if field_num == 1 and wire_type == 0:
|
|
535
|
+
result["timeToEndOfCharge"] = value
|
|
536
|
+
|
|
537
|
+
return result
|
|
538
|
+
except Exception:
|
|
539
|
+
_LOGGER.debug("Failed to decode time_estimation payload", exc_info=True)
|
|
540
|
+
return {}
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def decode_tires(payload: str) -> dict[str, Any]:
|
|
544
|
+
"""Decode dynamics.tires.state.
|
|
545
|
+
|
|
546
|
+
Returns dict with keys:
|
|
547
|
+
- tirePressureFrontLeft, tirePressureFrontRight, etc. (bar)
|
|
548
|
+
- tirePressureStatusFrontLeft, etc. ("OK")
|
|
549
|
+
"""
|
|
550
|
+
if not payload:
|
|
551
|
+
return {}
|
|
552
|
+
try:
|
|
553
|
+
data = base64.b64decode(payload)
|
|
554
|
+
fields = _decode_protobuf_fields(data)
|
|
555
|
+
result: dict[str, Any] = {}
|
|
556
|
+
|
|
557
|
+
for field_num, wire_type, value in fields:
|
|
558
|
+
if field_num == 2 and wire_type == 2: # Repeated nested tire state
|
|
559
|
+
inner = _decode_protobuf_fields(value)
|
|
560
|
+
pos = None
|
|
561
|
+
status = None
|
|
562
|
+
pressure = None
|
|
563
|
+
for in_num, in_type, in_val in inner:
|
|
564
|
+
if in_num == 1 and in_type == 0:
|
|
565
|
+
pos = in_val
|
|
566
|
+
elif in_num == 2 and in_type == 0:
|
|
567
|
+
status = "OK" if in_val == 1 else "Warning"
|
|
568
|
+
elif in_num == 3 and in_type == 1: # 64-bit float (bar)
|
|
569
|
+
pressure = round(in_val, 2)
|
|
570
|
+
|
|
571
|
+
if pos and pos in TIRE_POSITION_MAP:
|
|
572
|
+
suffix = TIRE_POSITION_MAP[pos]
|
|
573
|
+
if pressure is not None:
|
|
574
|
+
result[f"tirePressure{suffix}"] = pressure
|
|
575
|
+
if status is not None:
|
|
576
|
+
result[f"tirePressureStatus{suffix}"] = status
|
|
577
|
+
|
|
578
|
+
return result
|
|
579
|
+
except Exception:
|
|
580
|
+
_LOGGER.debug("Failed to decode tires payload", exc_info=True)
|
|
581
|
+
return {}
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
# Map of RVM topic -> decoder function
|
|
585
|
+
RVM_DECODERS: dict[str, Callable[[str], dict[str, Any]]] = {
|
|
586
|
+
"body.closures.states": decode_closures,
|
|
587
|
+
"body.locks.states": decode_locks,
|
|
588
|
+
"charging.session.status": decode_charging_session_status,
|
|
589
|
+
"charging.session.time_estimation": decode_time_estimation,
|
|
590
|
+
"comfort.cabin.cabin_preconditioning_status": decode_preconditioning,
|
|
591
|
+
"comfort.cabin.cabin_temperatures": decode_cabin_temperatures,
|
|
592
|
+
"comfort.cabin.defrost_defog_status": decode_defrost,
|
|
593
|
+
"dynamics.tires.state": decode_tires,
|
|
594
|
+
"dynamics.vehicle.gnss": decode_gnss,
|
|
595
|
+
"dynamics.vehicle.odometer": decode_odometer,
|
|
596
|
+
"energy.high_voltage.battery_state": decode_battery_state,
|
|
597
|
+
"energy_edge_compute.graphs.charge_session_breakdown": decode_charge_session_breakdown,
|
|
598
|
+
"energy_edge_compute.graphs.charging_graph_global": decode_charging_graph_global,
|
|
599
|
+
"vehicle.power.state": decode_power_state,
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
# Full list of Parallax RVMs subscribed for vehicle & charging telemetry
|
|
603
|
+
PARALLAX_RVMS: list[str] = list(RVM_DECODERS.keys())
|
|
604
|
+
CHARGING_RVMS: list[str] = [
|
|
605
|
+
"charging.session.notification",
|
|
606
|
+
"charging.session.remote_command",
|
|
607
|
+
"charging.session.soc_slider",
|
|
608
|
+
"charging.session.status",
|
|
609
|
+
"charging.session.time_estimation",
|
|
610
|
+
"energy.high_voltage.battery_state",
|
|
611
|
+
"energy_edge_compute.graphs.charge_session_breakdown",
|
|
612
|
+
"energy_edge_compute.graphs.charging_graph_global",
|
|
613
|
+
]
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def decode_parallax_message(
|
|
617
|
+
rvm: str, payload: str, **kwargs: Any
|
|
618
|
+
) -> dict[str, Any] | None:
|
|
619
|
+
"""Decode a Parallax message payload given its RVM topic.
|
|
620
|
+
|
|
621
|
+
Accepts the GraphQL message fields directly (rvm, payload, and optional kwargs/timestamp).
|
|
622
|
+
Returns a dict of decoded fields, or None if no decoder exists for this RVM.
|
|
623
|
+
"""
|
|
624
|
+
decoder = RVM_DECODERS.get(rvm)
|
|
625
|
+
if decoder is None:
|
|
626
|
+
_LOGGER.warning("Unknown Parallax RVM topic %s", rvm)
|
|
627
|
+
return None
|
|
628
|
+
return decoder(payload)
|
|
@@ -8,8 +8,8 @@ import socket
|
|
|
8
8
|
import sys
|
|
9
9
|
import time
|
|
10
10
|
import uuid
|
|
11
|
-
from collections.abc import Callable
|
|
12
|
-
from typing import Any
|
|
11
|
+
from collections.abc import Awaitable, Callable
|
|
12
|
+
from typing import Any
|
|
13
13
|
from warnings import warn
|
|
14
14
|
|
|
15
15
|
import aiohttp
|
|
@@ -33,13 +33,17 @@ from .exceptions import (
|
|
|
33
33
|
RivianTemporarilyLockedError,
|
|
34
34
|
RivianUnauthenticated,
|
|
35
35
|
)
|
|
36
|
+
from .parallax import PARALLAX_RVMS
|
|
36
37
|
from .utils import generate_vehicle_command_hmac
|
|
37
38
|
from .ws_monitor import WebSocketMonitor
|
|
38
39
|
|
|
39
40
|
if sys.version_info >= (3, 11):
|
|
40
41
|
import asyncio as async_timeout
|
|
42
|
+
from typing import Self
|
|
41
43
|
else:
|
|
42
44
|
import async_timeout
|
|
45
|
+
from typing_extensions import Self
|
|
46
|
+
|
|
43
47
|
|
|
44
48
|
_LOGGER = logging.getLogger(__name__)
|
|
45
49
|
|
|
@@ -82,7 +86,7 @@ LIVE_SESSION_VALUE_RECORD_KEYS = {
|
|
|
82
86
|
}
|
|
83
87
|
VALUE_RECORD_TEMPLATE = "{ __typename value updatedAt }"
|
|
84
88
|
|
|
85
|
-
ERROR_CODE_CLASS_MAP: dict[str,
|
|
89
|
+
ERROR_CODE_CLASS_MAP: dict[str, type[RivianApiException]] = {
|
|
86
90
|
"BAD_CURRENT_PASSWORD": RivianInvalidCredentials,
|
|
87
91
|
"BAD_REQUEST_ERROR": RivianBadRequestError,
|
|
88
92
|
"DATA_ERROR": RivianDataError,
|
|
@@ -444,6 +448,40 @@ class Rivian:
|
|
|
444
448
|
|
|
445
449
|
return await self.__graphql_query(headers, url, graphql_json)
|
|
446
450
|
|
|
451
|
+
async def get_charging_schedules(self, vehicle_id: str) -> ClientResponse:
|
|
452
|
+
"""Get charging schedules for a vehicle."""
|
|
453
|
+
url = GRAPHQL_GATEWAY
|
|
454
|
+
headers = BASE_HEADERS | {
|
|
455
|
+
"A-Sess": self._app_session_token,
|
|
456
|
+
"U-Sess": self._user_session_token,
|
|
457
|
+
}
|
|
458
|
+
graphql_json = {
|
|
459
|
+
"operationName": "getVehicleChargingSchedules",
|
|
460
|
+
"query": "query getVehicleChargingSchedules($vehicleId: String!) {\n getVehicle(id: $vehicleId) {\n chargingSchedules {\n weekDays\n startTime\n duration\n location {\n latitude\n longitude\n }\n amperage\n enabled\n }\n }\n}",
|
|
461
|
+
"variables": {"vehicleId": vehicle_id},
|
|
462
|
+
}
|
|
463
|
+
return await self.__graphql_query(headers, url, graphql_json)
|
|
464
|
+
|
|
465
|
+
async def set_charging_schedules(
|
|
466
|
+
self, vehicle_id: str, schedules: list[dict[str, Any]]
|
|
467
|
+
) -> ClientResponse:
|
|
468
|
+
"""Set charging schedules for a vehicle."""
|
|
469
|
+
url = GRAPHQL_GATEWAY
|
|
470
|
+
headers = BASE_HEADERS | {
|
|
471
|
+
"Csrf-Token": self._csrf_token,
|
|
472
|
+
"A-Sess": self._app_session_token,
|
|
473
|
+
"U-Sess": self._user_session_token,
|
|
474
|
+
}
|
|
475
|
+
graphql_json = {
|
|
476
|
+
"operationName": "setChargingSchedules",
|
|
477
|
+
"query": "mutation setChargingSchedules($vehicleId: String!, $chargingSchedules: [InputChargingSchedule!]!) {\n setChargingSchedules(vehicleId: $vehicleId, chargingSchedules: $chargingSchedules) {\n __typename\n success\n }\n}",
|
|
478
|
+
"variables": {
|
|
479
|
+
"vehicleId": vehicle_id,
|
|
480
|
+
"chargingSchedules": schedules,
|
|
481
|
+
},
|
|
482
|
+
}
|
|
483
|
+
return await self.__graphql_query(headers, url, graphql_json)
|
|
484
|
+
|
|
447
485
|
async def get_vehicle_ota_update_details(self, vehicle_id: str) -> ClientResponse:
|
|
448
486
|
"""Get vehicle OTA update details."""
|
|
449
487
|
url = GRAPHQL_GATEWAY
|
|
@@ -496,15 +534,14 @@ class Rivian:
|
|
|
496
534
|
self, command: VehicleCommand | str, params: dict[str, Any] | None = None
|
|
497
535
|
) -> None:
|
|
498
536
|
"""Validate certian vehicle command/param combos."""
|
|
499
|
-
if command == VehicleCommand.CHARGING_LIMITS
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
)
|
|
537
|
+
if command == VehicleCommand.CHARGING_LIMITS and not (
|
|
538
|
+
params
|
|
539
|
+
and isinstance((limit := params.get("SOC_limit")), int)
|
|
540
|
+
and 50 <= limit <= 100
|
|
541
|
+
):
|
|
542
|
+
raise RivianBadRequestError(
|
|
543
|
+
"Charging limit must include parameter `SOC_limit` with a valid value between 50 and 100"
|
|
544
|
+
)
|
|
508
545
|
if command in (
|
|
509
546
|
VehicleCommand.CABIN_HVAC_DEFROST_DEFOG,
|
|
510
547
|
VehicleCommand.CABIN_HVAC_LEFT_SEAT_HEAT,
|
|
@@ -514,15 +551,14 @@ class Rivian:
|
|
|
514
551
|
VehicleCommand.CABIN_HVAC_RIGHT_SEAT_HEAT,
|
|
515
552
|
VehicleCommand.CABIN_HVAC_RIGHT_SEAT_VENT,
|
|
516
553
|
VehicleCommand.CABIN_HVAC_STEERING_HEAT,
|
|
554
|
+
) and not (
|
|
555
|
+
params
|
|
556
|
+
and isinstance((level := params.get("level")), int)
|
|
557
|
+
and 0 <= level <= 4
|
|
517
558
|
):
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
and 0 <= level <= 4
|
|
522
|
-
):
|
|
523
|
-
raise RivianBadRequestError(
|
|
524
|
-
"HVAC setting must include parameter `level` with a valid value between 0 and 4"
|
|
525
|
-
)
|
|
559
|
+
raise RivianBadRequestError(
|
|
560
|
+
"HVAC setting must include parameter `level` with a valid value between 0 and 4"
|
|
561
|
+
)
|
|
526
562
|
if command == VehicleCommand.CABIN_PRECONDITIONING_SET_TEMP:
|
|
527
563
|
if not (
|
|
528
564
|
params
|
|
@@ -616,14 +652,44 @@ class Rivian:
|
|
|
616
652
|
unsubscribe = await self._ws_monitor.start_subscription(payload, callback)
|
|
617
653
|
_LOGGER.debug("%s subscribed to updates", vehicle_id)
|
|
618
654
|
return unsubscribe
|
|
619
|
-
except Exception as ex: # pylint: disable=broad-except
|
|
655
|
+
except Exception as ex: # pylint: disable=broad-except # noqa: BLE001
|
|
620
656
|
_LOGGER.error(ex)
|
|
621
657
|
return None
|
|
622
658
|
|
|
623
|
-
async def
|
|
659
|
+
async def subscribe_for_parallax_messages(
|
|
660
|
+
self,
|
|
661
|
+
vehicle_id: str,
|
|
662
|
+
callback: Callable[[dict[str, Any]], None],
|
|
663
|
+
rvms: list[str] | None = None,
|
|
664
|
+
) -> Callable[[], Awaitable[None]] | None:
|
|
665
|
+
"""Open a web socket connection to receive Parallax message updates."""
|
|
666
|
+
if not rvms:
|
|
667
|
+
rvms = PARALLAX_RVMS
|
|
668
|
+
|
|
669
|
+
try:
|
|
670
|
+
await self._ws_connect()
|
|
671
|
+
assert self._ws_monitor
|
|
672
|
+
async with async_timeout.timeout(self.request_timeout):
|
|
673
|
+
await self._ws_monitor.connection_ack.wait()
|
|
674
|
+
payload = {
|
|
675
|
+
"operationName": "ParallaxMessages",
|
|
676
|
+
"query": "subscription ParallaxMessages($vehicleId: String!, $rvms: [String!]) { parallaxMessages(vehicleId: $vehicleId, rvms: $rvms) { payload timestamp rvm } }",
|
|
677
|
+
"variables": {
|
|
678
|
+
"vehicleId": vehicle_id,
|
|
679
|
+
"rvms": rvms,
|
|
680
|
+
},
|
|
681
|
+
}
|
|
682
|
+
unsubscribe = await self._ws_monitor.start_subscription(payload, callback)
|
|
683
|
+
_LOGGER.debug("%s subscribed to %d Parallax RVMs", vehicle_id, len(rvms))
|
|
684
|
+
return unsubscribe
|
|
685
|
+
except Exception as ex: # pylint: disable=broad-except # noqa: BLE001
|
|
686
|
+
_LOGGER.error(ex)
|
|
687
|
+
return None
|
|
688
|
+
|
|
689
|
+
async def _ws_connect(self) -> ClientWebSocketResponse[bool]:
|
|
624
690
|
"""Initiate a websocket connection."""
|
|
625
691
|
|
|
626
|
-
async def connection_init(websocket: ClientWebSocketResponse) -> None:
|
|
692
|
+
async def connection_init(websocket: ClientWebSocketResponse[bool]) -> None:
|
|
627
693
|
await websocket.send_json(
|
|
628
694
|
{
|
|
629
695
|
"payload": {
|
|
@@ -676,37 +742,34 @@ class Rivian:
|
|
|
676
742
|
"Error occurred while communicating with Rivian."
|
|
677
743
|
) from exception
|
|
678
744
|
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
)
|
|
708
|
-
except Exception as exception:
|
|
709
|
-
raise exception
|
|
745
|
+
response_json = await response.json()
|
|
746
|
+
if errors := response_json.get("errors"):
|
|
747
|
+
for error in errors:
|
|
748
|
+
if extensions := error.get("extensions"):
|
|
749
|
+
code = extensions["code"]
|
|
750
|
+
if (code, extensions.get("reason")) in (
|
|
751
|
+
("BAD_USER_INPUT", "INVALID_OTP"),
|
|
752
|
+
("UNAUTHENTICATED", "OTP_TOKEN_EXPIRED"),
|
|
753
|
+
):
|
|
754
|
+
raise RivianInvalidOTP(
|
|
755
|
+
response.status, response_json, headers, body
|
|
756
|
+
)
|
|
757
|
+
if (code, extensions.get("reason")) == (
|
|
758
|
+
"CONFLICT",
|
|
759
|
+
"ENROLL_PHONE_LIMIT_REACHED",
|
|
760
|
+
):
|
|
761
|
+
raise RivianPhoneLimitReachedError(
|
|
762
|
+
response.status, response_json, headers, body
|
|
763
|
+
)
|
|
764
|
+
if err_cls := ERROR_CODE_CLASS_MAP.get(code):
|
|
765
|
+
raise err_cls(response.status, response_json, headers, body)
|
|
766
|
+
raise RivianApiException(
|
|
767
|
+
"Error occurred while reading the graphql response from Rivian.",
|
|
768
|
+
response.status,
|
|
769
|
+
response_json,
|
|
770
|
+
headers,
|
|
771
|
+
body,
|
|
772
|
+
)
|
|
710
773
|
|
|
711
774
|
return response
|
|
712
775
|
|
|
@@ -717,7 +780,7 @@ class Rivian:
|
|
|
717
780
|
if self._session and self._close_session:
|
|
718
781
|
await self._session.close()
|
|
719
782
|
|
|
720
|
-
async def __aenter__(self) ->
|
|
783
|
+
async def __aenter__(self) -> Self:
|
|
721
784
|
"""Async enter.
|
|
722
785
|
Returns:
|
|
723
786
|
The Rivian object.
|
|
@@ -14,6 +14,7 @@ from typing import TYPE_CHECKING, Any
|
|
|
14
14
|
from uuid import uuid4
|
|
15
15
|
|
|
16
16
|
from aiohttp import ClientWebSocketResponse, WSMessage, WSMsgType
|
|
17
|
+
from aiohttp.http_websocket import WSMessageTextBytes
|
|
17
18
|
|
|
18
19
|
if sys.version_info >= (3, 11):
|
|
19
20
|
import asyncio as async_timeout
|
|
@@ -44,7 +45,7 @@ class WebSocketMonitor:
|
|
|
44
45
|
self,
|
|
45
46
|
account: Rivian,
|
|
46
47
|
url: str,
|
|
47
|
-
connection_init: Callable[[ClientWebSocketResponse], Awaitable[None]],
|
|
48
|
+
connection_init: Callable[[ClientWebSocketResponse[bool]], Awaitable[None]],
|
|
48
49
|
) -> None:
|
|
49
50
|
"""Initialize a web socket monitor."""
|
|
50
51
|
self._account = account
|
|
@@ -53,7 +54,7 @@ class WebSocketMonitor:
|
|
|
53
54
|
|
|
54
55
|
self._connection_ack: asyncio.Event = asyncio.Event()
|
|
55
56
|
self._disconnect = False
|
|
56
|
-
self._ws: ClientWebSocketResponse | None = None
|
|
57
|
+
self._ws: ClientWebSocketResponse[bool] | None = None
|
|
57
58
|
self._monitor_task: asyncio.Task | None = None
|
|
58
59
|
self._receiver_task: asyncio.Task | None = None
|
|
59
60
|
self._last_received: datetime | None = None
|
|
@@ -74,7 +75,7 @@ class WebSocketMonitor:
|
|
|
74
75
|
return self._connection_ack
|
|
75
76
|
|
|
76
77
|
@property
|
|
77
|
-
def websocket(self) -> ClientWebSocketResponse | None:
|
|
78
|
+
def websocket(self) -> ClientWebSocketResponse[bool] | None:
|
|
78
79
|
"""Return the web socket."""
|
|
79
80
|
return self._ws
|
|
80
81
|
|
|
@@ -179,7 +180,7 @@ class WebSocketMonitor:
|
|
|
179
180
|
if not self._disconnect:
|
|
180
181
|
try:
|
|
181
182
|
await self.new_connection()
|
|
182
|
-
except Exception as ex: # pylint: disable=broad-except
|
|
183
|
+
except Exception as ex: # pylint: disable=broad-except # noqa: BLE001
|
|
183
184
|
self._log_message(ex, True)
|
|
184
185
|
if not self._ws or self._ws.closed:
|
|
185
186
|
await asyncio.sleep(min(1 * 2**attempt + uniform(0, 1), 300))
|
|
@@ -206,7 +207,9 @@ class WebSocketMonitor:
|
|
|
206
207
|
await cancel_task(self._monitor_task, self._receiver_task)
|
|
207
208
|
|
|
208
209
|
def _log_message(
|
|
209
|
-
self,
|
|
210
|
+
self,
|
|
211
|
+
message: str | Exception | WSMessage | WSMessageTextBytes,
|
|
212
|
+
is_error: bool = False,
|
|
210
213
|
) -> None:
|
|
211
214
|
"""Log a message."""
|
|
212
215
|
log_method = _LOGGER.error if is_error else _LOGGER.debug
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: rivian-python-client
|
|
3
|
-
Version: 2.0.0
|
|
4
|
-
Summary: Rivian API Client (Unofficial)
|
|
5
|
-
License-Expression: MIT
|
|
6
|
-
Author: Brian Retterer
|
|
7
|
-
Author-email: bretterer@gmail.com
|
|
8
|
-
Requires-Python: >=3.10
|
|
9
|
-
Classifier: Programming Language :: Python :: 3
|
|
10
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
-
Provides-Extra: ble
|
|
16
|
-
Requires-Dist: aiohttp (>=3.0.0)
|
|
17
|
-
Requires-Dist: backports-strenum (>=1.2.4) ; python_version < "3.11"
|
|
18
|
-
Requires-Dist: bleak (>=0.21) ; extra == "ble"
|
|
19
|
-
Requires-Dist: cryptography (>=41.0.1)
|
|
20
|
-
Requires-Dist: dbus-fast (>=2.11.0) ; (sys_platform == "linux") and (extra == "ble")
|
|
21
|
-
Description-Content-Type: text/markdown
|
|
22
|
-
|
|
23
|
-
# Python: Rivian API Client
|
|
24
|
-
|
|
25
|
-
Currently a Work In Progress
|
|
26
|
-
|
|
27
|
-
## Dependencies
|
|
28
|
-
|
|
29
|
-
[Poetry](https://python-poetry.org/docs/)
|
|
30
|
-
|
|
31
|
-
```
|
|
32
|
-
curl -sSL https://install.python-poetry.org | python3 -
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
## Setup
|
|
36
|
-
|
|
37
|
-
Install project dependencies into the poetry virtual environment and run pre-commit
|
|
38
|
-
|
|
39
|
-
```
|
|
40
|
-
poetry install --all-extras
|
|
41
|
-
pre-commit install
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
## Run Tests
|
|
45
|
-
|
|
46
|
-
```
|
|
47
|
-
poetry run pytest
|
|
48
|
-
```
|
|
49
|
-
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
# Python: Rivian API Client
|
|
2
|
-
|
|
3
|
-
Currently a Work In Progress
|
|
4
|
-
|
|
5
|
-
## Dependencies
|
|
6
|
-
|
|
7
|
-
[Poetry](https://python-poetry.org/docs/)
|
|
8
|
-
|
|
9
|
-
```
|
|
10
|
-
curl -sSL https://install.python-poetry.org | python3 -
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
## Setup
|
|
14
|
-
|
|
15
|
-
Install project dependencies into the poetry virtual environment and run pre-commit
|
|
16
|
-
|
|
17
|
-
```
|
|
18
|
-
poetry install --all-extras
|
|
19
|
-
pre-commit install
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
## Run Tests
|
|
23
|
-
|
|
24
|
-
```
|
|
25
|
-
poetry run pytest
|
|
26
|
-
```
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
[project]
|
|
2
|
-
name = "rivian-python-client"
|
|
3
|
-
description = "Rivian API Client (Unofficial)"
|
|
4
|
-
license = "MIT"
|
|
5
|
-
readme = "README.md"
|
|
6
|
-
requires-python = ">=3.10"
|
|
7
|
-
authors = [
|
|
8
|
-
{ name = "Brian Retterer", email = "bretterer@gmail.com" },
|
|
9
|
-
{ name = "J.R. Gutierrez", email = "jrgutier@gmail.com" },
|
|
10
|
-
{ name = "Nathan Spencer", email = "natekspencer@gmail.com" },
|
|
11
|
-
{ name = "Trevor Mack", email = "drummer8001@gmail.com" },
|
|
12
|
-
]
|
|
13
|
-
dependencies = [
|
|
14
|
-
"aiohttp (>=3.0.0)",
|
|
15
|
-
"cryptography (>=41.0.1)",
|
|
16
|
-
"backports-strenum (>=1.2.4) ; python_version < '3.11'",
|
|
17
|
-
]
|
|
18
|
-
dynamic = []
|
|
19
|
-
version = "2.0.0"
|
|
20
|
-
|
|
21
|
-
[project.optional-dependencies]
|
|
22
|
-
ble = [
|
|
23
|
-
"bleak (>=0.21)",
|
|
24
|
-
"dbus-fast (>=2.11.0) ; sys_platform == 'linux'",
|
|
25
|
-
]
|
|
26
|
-
|
|
27
|
-
[tool.poetry]
|
|
28
|
-
packages = [
|
|
29
|
-
{ include = "rivian", from = "src" },
|
|
30
|
-
]
|
|
31
|
-
|
|
32
|
-
[tool.poetry.group.dev.dependencies]
|
|
33
|
-
pytest = ">=7.1.2,<9.0.0"
|
|
34
|
-
pytest-asyncio = ">=0.18.3,<1.3.0"
|
|
35
|
-
python-dotenv = ">=0.20,<1.3"
|
|
36
|
-
aresponses = ">=2.1.5,<4.0.0"
|
|
37
|
-
mypy = ">=1.7.0,<2.0.0"
|
|
38
|
-
ruff = ">=0.4,<0.15"
|
|
39
|
-
pre-commit = "^4.1.0"
|
|
40
|
-
|
|
41
|
-
[tool.poetry-dynamic-versioning]
|
|
42
|
-
enable = false
|
|
43
|
-
vcs = "git"
|
|
44
|
-
style = "semver"
|
|
45
|
-
pattern = "default-unprefixed"
|
|
46
|
-
|
|
47
|
-
[tool.ruff.lint.isort]
|
|
48
|
-
combine-as-imports = true
|
|
49
|
-
split-on-trailing-comma = false
|
|
50
|
-
|
|
51
|
-
[build-system]
|
|
52
|
-
requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning"]
|
|
53
|
-
build-backend = "poetry_dynamic_versioning.backend"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rivian_python_client-2.0.0 → rivian_python_client-2.1.0}/src/rivian/schemas/charging.graphql
RENAMED
|
File without changes
|
{rivian_python_client-2.0.0 → rivian_python_client-2.1.0}/src/rivian/schemas/gateway.graphql
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|