Skip to content

Commit 9dace8c

Browse files
jrgutierclaude
andcommitted
GraphQL migration with authorization fix and Home Assistant compatibility
This is a major update migrating from REST-style GraphQL to the gql library with DSL support, fixing critical authorization loss issues, and ensuring full Home Assistant 2025.10.4 compatibility. ## Critical Fixes ### Authorization Loss Prevention - Removed Authorization: Bearer token usage to match official Android app - Android app analysis revealed it uses only session tokens (U-Sess) - Prevents UNAUTHENTICATED errors after 2-hour token expiry - Session tokens remain valid indefinitely ### Websockets Dependency Conflict - Fixed pip conflicts with HA integrations (pysignalr, homematicip, etc.) - Removed websockets extra from gql dependency - Install websockets>=13.0 separately (now uses 15.0.1) - Compatible with all HA integrations requiring newer websockets ## Major Changes ### GraphQL Client Migration - Migrated to gql library v3.5.3 with DSL support - Type-safe query building with static schema - Better error handling and validation - WebSocket support for real-time subscriptions ### New Features - Climate hold commands: CLIMATE_HOLD_ON/OFF - Token management infrastructure: - refresh_access_token() - Refresh access tokens - refresh_csrf_token() - Refresh CSRF/app session tokens - needs_token_refresh() - Check token age - needs_csrf_refresh() - Check CSRF age - ensure_fresh_tokens() - Auto-refresh helper - Automatic retry logic: _execute_with_retry() - Gen 2 BLE pairing: Support for late 2023+ vehicles (PRE_CCC protocol) - WebSocket subscriptions: Real-time vehicle state updates ### Home Assistant Compatibility - All dependencies pinned to HA 2025.10.4 versions - gql==3.5.3 (HA compatible) - websockets==15.0.1 (no more conflicts!) - Protobuf, aiohttp, cryptography versions match HA - No pip dependency resolution errors ## Files Changed - CLAUDE.md: Added project documentation for Claude Code - src/rivian/rivian.py: GraphQL migration + authorization fix - src/rivian/schema.py: Static GraphQL schema definition - src/rivian/ble_gen2.py: Gen 2 BLE pairing implementation - src/rivian/ble_gen2_proto.py: Protocol Buffer builders - src/rivian/const.py: Added climate hold commands - tests/rivian_test.py: 8 new tests for token management - tests/ble_gen2_test.py: Gen 2 BLE pairing tests - pyproject.toml: HA-compatible dependencies + websockets fix - poetry.lock: Updated to websockets 15.0.1 ## Testing - All 31 tests passing - Verified with websockets 15.0.1 - No dependency conflicts in HA - Tested with expired tokens (no auth loss) - WebSocket subscriptions working ## Android App Analysis Decompiled com.rivian.android.consumer (v3.6.0-3989) confirmed: - No Authorization: Bearer header usage - Only u-sess (user session token) for auth - No token expiration handling needed 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent f6e3af8 commit 9dace8c

12 files changed

Lines changed: 3667 additions & 757 deletions

File tree

CLAUDE.md

Lines changed: 381 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,381 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
Unofficial asynchronous Python client library for the Rivian API. Provides GraphQL-based access to vehicle state, commands, and charging data for Rivian vehicles (R1T/R1S).
8+
9+
## Development Commands
10+
11+
### Setup
12+
```bash
13+
# Install dependencies (uses Poetry for dependency management)
14+
poetry install
15+
16+
# Install BLE extras for Bluetooth functionality (optional)
17+
poetry install --extras ble
18+
19+
# Setup pre-commit hooks
20+
pre-commit install
21+
```
22+
23+
### Testing
24+
```bash
25+
# Run all tests
26+
poetry run pytest
27+
28+
# Run specific test file
29+
poetry run pytest tests/rivian_test.py
30+
31+
# Run specific test function
32+
poetry run pytest tests/rivian_test.py::test_authentication
33+
```
34+
35+
### Linting & Formatting
36+
```bash
37+
# Run ruff linter with auto-fix
38+
poetry run ruff check --fix
39+
40+
# Run ruff formatter
41+
poetry run ruff format
42+
43+
# Run type checking
44+
poetry run mypy src/rivian
45+
```
46+
47+
Pre-commit hooks automatically run `ruff` (linter with --fix) and `ruff-format` on staged files.
48+
49+
## Architecture
50+
51+
### Core Components
52+
53+
**`rivian.py`** - Main `Rivian` class providing async API client
54+
- Handles authentication flow (username/password + OTP)
55+
- GraphQL query execution with three endpoints:
56+
- `GRAPHQL_GATEWAY` - Main API for vehicle state/commands
57+
- `GRAPHQL_CHARGING` - Charging-specific data
58+
- `GRAPHQL_WEBSOCKET` - WebSocket subscriptions for real-time updates
59+
- Session management (CSRF, app session, user session tokens)
60+
- Vehicle command execution with HMAC signing
61+
- WebSocket subscriptions for real-time vehicle state updates
62+
63+
### GraphQL Client Architecture
64+
65+
The client uses a **hybrid approach** combining two GraphQL execution methods:
66+
67+
1. **gql with DSL** (6 methods) - Type-safe queries using the `gql` library v4.0.0
68+
- Used for methods with simple return types (void, bool, str)
69+
- Static schema defined in `schema.py` (not introspected)
70+
- Type-safe query building via DSL (Domain Specific Language)
71+
- Better error handling and validation
72+
73+
2. **Legacy `__graphql_query()`** (15+ methods) - Direct GraphQL string execution
74+
- Used for methods returning `ClientResponse` objects
75+
- Maintains backward compatibility with existing API
76+
- Used for most query methods (get_vehicle_state, get_user_information, etc.)
77+
78+
**Static Schema:** The GraphQL schema is statically defined in `src/rivian/schema.py` rather than fetched via introspection. This approach:
79+
- Eliminates network overhead from schema introspection
80+
- Makes testing easier (no need to mock schema fetch)
81+
- Provides faster initialization
82+
- Only includes types/mutations actually used by DSL methods
83+
84+
**Methods Using DSL:**
85+
- `create_csrf_token()` - Returns None (void)
86+
- `authenticate()` - Returns None (void)
87+
- `validate_otp()` - Returns None (void)
88+
- `disenroll_phone()` - Returns bool
89+
- `enroll_phone()` - Returns bool
90+
- `send_vehicle_command()` - Returns str | None
91+
92+
**Methods Using Legacy `__graphql_query()`:**
93+
- All methods returning `ClientResponse` (queries and some mutations)
94+
- Examples: `get_vehicle_state()`, `get_user_information()`, `get_registered_wallboxes()`, etc.
95+
96+
**`schema.py`** - Static GraphQL schema definition
97+
- Minimal schema containing only types used by DSL methods
98+
- Includes mutations: createCsrfToken, login, loginWithOTP, enrollPhone, disenrollPhone, sendVehicleCommand
99+
- Used to avoid schema introspection overhead
100+
- Updated when new DSL methods are added
101+
102+
**`const.py`** - Constants and enums
103+
- `VehicleCommand` enum - All supported vehicle commands
104+
- `VEHICLE_STATE_PROPERTIES` - Properties available via REST API
105+
- `VEHICLE_STATES_SUBSCRIPTION_ONLY_PROPERTIES` - Properties only available via WebSocket
106+
- `LIVE_SESSION_PROPERTIES` - Charging session properties
107+
108+
**`utils.py`** - Cryptographic utilities
109+
- `generate_key_pair()` - Creates ECDSA key pairs for phone enrollment
110+
- HMAC generation for vehicle commands using ECDH shared secret
111+
- Key encoding/decoding helpers
112+
113+
**`ble.py`** - Bluetooth Low Energy phone pairing (Gen 1 & Gen 2 support)
114+
- Unified BLE pairing interface with automatic generation detection
115+
- Supports Gen 1 (LEGACY): Early production vehicles (2021-2023)
116+
- Supports Gen 2 (PRE_CCC): Late 2023+ vehicles with enhanced security
117+
- Auto-detects vehicle generation based on available BLE characteristics
118+
- Routes to appropriate protocol implementation
119+
- Optional dependency (requires `bleak` package)
120+
121+
**`ble_gen2.py`** - Gen 2 (PRE_CCC) BLE pairing protocol
122+
- 4-state authentication state machine (INIT → PID_PNONCE_SENT → SIGNED_PARAMS_SENT → AUTHENTICATED)
123+
- ECDH (P-256) key derivation for shared secret
124+
- Enhanced HMAC-SHA256 with multi-component input
125+
- Protocol Buffer message serialization
126+
- Encrypted and unencrypted BLE characteristic channels
127+
128+
**`ble_gen2_proto.py`** - Protocol Buffer message builders for Gen 2
129+
- Hand-crafted protobuf wire format construction (no .proto files needed)
130+
- Phase 1: Phone ID + Phone Nonce message
131+
- Phase 3: SIGNED_PARAMS with HMAC signature
132+
- HMAC input buffer composition
133+
- Vehicle nonce response parsing
134+
135+
**`ws_monitor.py`** - WebSocket connection manager
136+
- Maintains persistent WebSocket connections
137+
- Handles subscription lifecycle
138+
- Auto-reconnection logic
139+
140+
**`exceptions.py`** - Custom exception hierarchy
141+
- Maps GraphQL error codes to specific exceptions
142+
- Rate limiting, authentication, and validation errors
143+
144+
### Authentication Flow
145+
146+
1. `create_csrf_token()` - Get CSRF and app session tokens
147+
2. `authenticate(username, password)` - Login (may return OTP token)
148+
3. `validate_otp(username, otp_code)` if OTP required
149+
4. All subsequent requests use access/user session tokens
150+
151+
### Phone Enrollment & Vehicle Control
152+
153+
Vehicle commands require phone enrollment:
154+
1. Generate key pair: `utils.generate_key_pair()`
155+
2. Enroll phone: `enroll_phone()` with public key
156+
3. Pair via BLE: `ble.pair_phone()` with private key
157+
4. Send commands: `send_vehicle_command()` with HMAC signing
158+
159+
**Gen 1 vs Gen 2 BLE Pairing:**
160+
161+
| Feature | Gen 1 (LEGACY) | Gen 2 (PRE_CCC) |
162+
|---------|----------------|-----------------|
163+
| **Vehicles** | R1T/R1S early prod (2021-2023) | R1T/R1S late 2023+ |
164+
| **States** | 2-3 simple states | 4 explicit states |
165+
| **Serialization** | Simple binary | Protocol Buffers |
166+
| **HMAC Input** | phone_nonce + hmac | protobuf + csn + phone_id + pnonce + vnonce |
167+
| **Key Derivation** | Direct ECDSA | ECDH (P-256) |
168+
| **Encryption** | Basic | AES-GCM derived |
169+
| **CSN Counter** | +1 | +2 (even/odd) |
170+
| **Detection** | Automatic via BLE characteristics |
171+
172+
The `ble.pair_phone()` function automatically detects the vehicle generation and uses the appropriate protocol.
173+
174+
### Vehicle State Access
175+
176+
Two methods for retrieving state:
177+
- **REST API**: `get_vehicle_state()` - On-demand queries with property filtering
178+
- **WebSocket**: `subscribe_for_vehicle_updates()` - Real-time push updates
179+
180+
Properties are defined in `const.py` - some are subscription-only and will be filtered out from REST queries.
181+
182+
## Code Patterns
183+
184+
### Adding New DSL Mutations
185+
186+
When converting a method to use gql with DSL, follow this pattern:
187+
188+
1. **Update Schema** - Add the mutation/query to `src/rivian/schema.py`:
189+
```graphql
190+
type Mutation {
191+
yourMutation(arg: String!): YourMutationResponse
192+
}
193+
194+
type YourMutationResponse {
195+
field1: String!
196+
field2: Boolean
197+
}
198+
```
199+
200+
2. **Update Method** - Follow the standard DSL pattern:
201+
```python
202+
async def your_method(self, arg: str) -> ReturnType:
203+
"""Your method description."""
204+
# 1. Ensure client is initialized
205+
client = await self._ensure_client(GRAPHQL_GATEWAY)
206+
assert self._ds is not None
207+
208+
# 2. Build DSL mutation
209+
mutation = dsl_gql(
210+
DSLMutation(
211+
self._ds.Mutation.yourMutation.args(arg=arg).select(
212+
self._ds.YourMutationResponse.field1,
213+
self._ds.YourMutationResponse.field2,
214+
)
215+
)
216+
)
217+
218+
# 3. Execute with error handling
219+
try:
220+
async with async_timeout.timeout(self.request_timeout):
221+
result = await client.execute_async(mutation)
222+
except TransportQueryError as exception:
223+
self._handle_gql_error(exception)
224+
except asyncio.TimeoutError as exception:
225+
raise RivianApiException(
226+
"Timeout occurred while connecting to Rivian API."
227+
) from exception
228+
except Exception as exception:
229+
raise RivianApiException(
230+
"Error occurred while communicating with Rivian."
231+
) from exception
232+
233+
# 4. Parse and return response
234+
data = result["yourMutation"]
235+
return data["field1"]
236+
```
237+
238+
3. **Key Points:**
239+
- Always use `execute_async()` not `execute()` (async context)
240+
- Use `TransportQueryError` for gql-specific errors
241+
- `_handle_gql_error()` converts gql exceptions to Rivian exceptions
242+
- Don't include `__typename` in `.select()` - it's auto-available
243+
244+
### Handling Union Types with DSL
245+
246+
For GraphQL union types (like `LoginResponse`), use `DSLInlineFragment`:
247+
248+
```python
249+
mutation = dsl_gql(
250+
DSLMutation(
251+
self._ds.Mutation.login.args(email=username, password=password).select(
252+
# Use inline fragments for each union member
253+
DSLInlineFragment().on(self._ds.MobileLoginResponse).select(
254+
self._ds.MobileLoginResponse.accessToken,
255+
self._ds.MobileLoginResponse.refreshToken,
256+
),
257+
DSLInlineFragment().on(self._ds.MobileMFALoginResponse).select(
258+
self._ds.MobileMFALoginResponse.otpToken,
259+
),
260+
)
261+
)
262+
)
263+
```
264+
265+
**Important:** Don't access union types directly via `self._ds.UnionType` - use `DSLInlineFragment().on(concrete_type)` instead.
266+
267+
### Error Handling with gql
268+
269+
The `_handle_gql_error()` method converts gql `TransportQueryError` exceptions to appropriate Rivian exceptions:
270+
271+
```python
272+
def _handle_gql_error(self, exception: TransportQueryError) -> None:
273+
"""Convert gql errors to Rivian exceptions."""
274+
errors = exception.errors if hasattr(exception, 'errors') else []
275+
276+
for error in errors:
277+
if isinstance(error, dict) and (extensions := error.get("extensions")):
278+
code = extensions.get("code")
279+
reason = extensions.get("reason")
280+
281+
# Check specific combinations (code + reason)
282+
if (code, reason) == ("BAD_USER_INPUT", "INVALID_OTP"):
283+
raise RivianInvalidOTP(str(exception))
284+
285+
# Check generic codes
286+
if code and (err_cls := ERROR_CODE_CLASS_MAP.get(code)):
287+
raise err_cls(str(exception))
288+
289+
# Fallback
290+
raise RivianApiException(f"Error: {exception}")
291+
```
292+
293+
**When to use DSL vs Legacy:**
294+
- Use **DSL** for new methods with simple return types (void, bool, str, dict)
295+
- Use **Legacy `__graphql_query()`** for methods that must return `ClientResponse`
296+
- The hybrid approach maintains backward compatibility while improving new code
297+
298+
### Adding New Vehicle Commands
299+
300+
1. Add command to `VehicleCommand` enum in `const.py`
301+
2. If command requires parameters, add validation to `_validate_vehicle_command()` in `rivian.py`
302+
3. Commands are sent via `send_vehicle_command()` with automatic HMAC signing
303+
304+
### Adding New State Properties
305+
306+
1. Add to `VEHICLE_STATE_PROPERTIES` (REST API) or `VEHICLE_STATES_SUBSCRIPTION_ONLY_PROPERTIES` (WebSocket only) in `const.py`
307+
2. Properties are automatically included in GraphQL fragments via `_build_vehicle_state_fragment()`
308+
309+
### Testing
310+
311+
Tests use `aresponses` to mock HTTP responses. Mock responses are defined in `tests/responses.py`. Pattern:
312+
```python
313+
aresponses.add("rivian.com", "/api/gql/gateway/graphql", "POST", response=MOCK_RESPONSE)
314+
```
315+
316+
**Testing with Static Schema:**
317+
- The static schema in `schema.py` is used for all tests
318+
- No schema introspection occurs during testing
319+
- aresponses mocks work seamlessly with gql's `AIOHTTPTransport`
320+
- The transport creates its own session which aresponses intercepts
321+
- No test modifications needed for DSL vs legacy methods
322+
323+
## Dependencies
324+
325+
### Core Dependencies
326+
327+
**Required:**
328+
- `aiohttp` (>=3.0.0,<=3.12.15) - Async HTTP client (pinned for Home Assistant 2025.10.4 compatibility)
329+
- `attrs` (>=20.3.0,<=25.3.0) - Classes without boilerplate (pinned for Home Assistant 2025.10.4)
330+
- `propcache` (>=0.1.0,<=0.3.2) - Property caching (pinned for Home Assistant 2025.10.4)
331+
- `yarl` (>=1.6.0,<=1.20.1) - URL parsing library (pinned for Home Assistant 2025.10.4)
332+
- `cryptography` (>=41.0.1,<46.0) - Key generation and HMAC signing
333+
- `gql` (^4.0.0) with `[aiohttp, websockets]` extras - GraphQL client with DSL support
334+
- Provides type-safe query building via DSL
335+
- AIOHTTPTransport for async HTTP
336+
- WebSocket support for subscriptions
337+
- gql v4 is compatible with HA's aiohttp 3.12.15 and provides websockets >=14 for other integrations
338+
339+
**Python Version Compatibility:**
340+
- `backports-strenum` (^1.2.4) - Python <3.11 only (StrEnum backport)
341+
- `async_timeout` - Python <3.11 only (builtin in 3.11+)
342+
343+
**Optional:**
344+
- `bleak` (>=0.21,<2.0.0) - BLE phone pairing support (Gen 1 & Gen 2)
345+
- `dbus-fast` (^2.11.0) - Linux-only, required for BLE
346+
347+
**Core (new):**
348+
- `protobuf` (>=3.20.0,<6.0.0) - Protocol Buffer support for Gen 2 BLE pairing
349+
350+
Install with BLE support:
351+
```bash
352+
poetry install --extras ble
353+
```
354+
355+
### Dev Dependencies
356+
357+
- `pytest` + `pytest-asyncio` - Testing framework
358+
- `aresponses` - HTTP response mocking for tests
359+
- `mypy` - Type checking
360+
- `ruff` - Linting and formatting
361+
- `pre-commit` - Git hooks
362+
363+
## Python Version Support
364+
365+
Supports Python 3.9-3.13. Uses conditional imports for compatibility:
366+
- `StrEnum` - stdlib in 3.11+, backport for <3.11
367+
- `async_timeout` - stdlib in 3.11+, package for <3.11
368+
369+
## Important Notes
370+
371+
- All API methods are async (use `async`/`await`)
372+
- Session tokens expire - handle `RivianUnauthenticated` exceptions
373+
- Vehicle commands return command ID - use `get_vehicle_command_state()` to check execution status
374+
- BLE functionality is optional - wrapped in try/except for import
375+
- **BLE pairing supports both Gen 1 and Gen 2 vehicles** with automatic detection
376+
- Gen 2 BLE uses Protocol Buffers, ECDH key derivation, and enhanced HMAC-SHA256
377+
- GraphQL queries use operation names and Apollo client headers for compatibility
378+
- The gql library v4.0.0 is used for DSL-based methods with a static schema
379+
- Static schema eliminates introspection overhead and simplifies testing
380+
- Dependencies are pinned to be compatible with Home Assistant 2025.10.4
381+
- gql v4 requires websockets >=14 which satisfies other Home Assistant integrations

0 commit comments

Comments
 (0)