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