Metadata-Version: 2.4
Name: aioratio
Version: 0.10.0
Summary: Async Python client for the Ratio EV Charging cloud API
Project-URL: Homepage, https://github.com/aaearon/aioratio
Project-URL: Source, https://github.com/aaearon/aioratio
Project-URL: Issues, https://github.com/aaearon/aioratio/issues
Project-URL: Changelog, https://github.com/aaearon/aioratio/releases
Author-email: Tim <tim@iosharp.com>
License: MIT
License-File: LICENSE
Keywords: async,charger,cognito,ev,home-assistant,ratio
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Home Automation
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: aiohttp<5,>=3.9
Provides-Extra: ble
Requires-Dist: bleak<4,>=0.22; extra == 'ble'
Provides-Extra: dev
Requires-Dist: bleak<4,>=0.22; extra == 'dev'
Requires-Dist: mypy>=1.8; extra == 'dev'
Requires-Dist: pytest-aiohttp>=1.0; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

# aioratio

Async Python client for the [Ratio](https://www.ratio-electric.com/) EV Charging cloud API.

[![PyPI](https://img.shields.io/pypi/v/aioratio.svg)](https://pypi.org/project/aioratio/)
[![Python](https://img.shields.io/pypi/pyversions/aioratio.svg)](https://pypi.org/project/aioratio/)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)

## What this is

A standalone, async, dependency-light Python client for the cloud API behind
the official Ratio EV Charging mobile app. Authenticates via AWS Cognito
(USER_SRP_AUTH + DEVICE_SRP_AUTH), persists device and refresh tokens,
exposes typed dataclass models for chargers, sessions, settings, and
vehicles. Designed to be embedded in Home Assistant integrations or used
directly from scripts.

An optional `[ble]` extra adds a local Inspiro IPC BLE client
(`aioratio.BleClient`) for charger control without going through the cloud.

This is an unofficial library. Not affiliated with Ratio.

## Install

```bash
pip install aioratio
```

Requires Python 3.11+. Only runtime dep is `aiohttp>=3.9`.

## Quick start

```python
import asyncio
import aiohttp
from aioratio import RatioClient, JsonFileTokenStore

async def main() -> None:
    async with aiohttp.ClientSession() as session:
        client = RatioClient(
            email="you@example.com",
            password="...",
            token_store=JsonFileTokenStore("ratio-tokens.json"),
            session=session,
        )
        async with client:
            chargers = await client.chargers_overview()
            for c in chargers:
                print(c.serial_number, c.cloud_connection_state)

            await client.start_charge(chargers[0].serial_number,
                                      vehicle_id="<your-vehicle-id>")

asyncio.run(main())
```

The token store persists access/refresh tokens **and** Cognito device
metadata (DeviceKey, DeviceGroupKey, DevicePassword) so subsequent runs
go through the DEVICE_SRP_AUTH fast path without re-prompting.

## Public API

`RatioClient` is the entry point. Construct with email + password (and
optionally a `TokenStore`), use as an async context manager.

| Method | Returns | Notes |
|---|---|---|
| `login()` | `None` | Force a fresh login. Idempotent. |
| `user_id()` | `str` | Cognito `sub` claim from the IdToken. |
| `chargers()` | `list[Charger]` | Bare charger registry. |
| `chargers_overview()` | `list[ChargerOverview]` | Aggregate status of all chargers (single call). |
| `charger_overview(serial)` | `ChargerOverview` | Single-charger full state. |
| `start_charge(serial, vehicle_id=None)` | `None` | `vehicle_id` is optional but recommended; if omitted, the client sends an empty `startCommandParameters` object. |
| `stop_charge(serial)` | `None` | |
| `user_settings(serial)` | `UserSettings` | |
| `set_user_settings(serial, settings)` | `None` | Accepts `UserSettings` dataclass (recommended) or a pre-formed camelCase dict. |
| `charge_schedule(serial)` | `ChargeSchedule` | |
| `set_charge_schedule(serial, schedule)` | `None` | |
| `solar_settings(serial)` | `SolarSettings` | |
| `set_solar_settings(serial, settings)` | `None` | Accepts `SolarSettings` dataclass (recommended) or a pre-formed camelCase dict. |
| `grant_upgrade_permission(serial, firmware_update_job_ids)` | `None` | Approve queued firmware update jobs by id. Raises `ValueError` if the list is empty. |
| `diagnostics(serial)` | `ChargerDiagnostics` | Read-only system info: hardware/firmware versions, network status (WiFi/ethernet), backend connectivity, OCPP status. |
| `ocpp_settings(serial)` | `InstallerOcppSettings` | Read installer OCPP settings including `is_change_allowed` metadata per field. |
| `set_ocpp_settings(serial, settings)` | `None` | Write OCPP settings (enabled, cpms, charge_point_identifier). Accepts `InstallerOcppSettings` or a flat dict. |
| `cpms_options(serial)` | `list[CpmsConfig]` | List operator-provided CPMS choices. Returns `[]` on 403/error (operator may not expose this). |
| `session_history(...)` | `SessionHistoryPage` | Paginated; pass `next_token` to continue. |
| `vehicles()` | `list[Vehicle]` | |
| `add_vehicle(vehicle)` | `Vehicle` | |
| `remove_vehicle(vehicle_id)` | `None` | |

Errors:

- `RatioAuthError` -- credentials invalid, refresh expired, unsupported Cognito challenge.
- `RatioApiError` -- non-2xx response from the REST API. Also raised when calling methods on a closed client.
- `RatioRateLimitError` -- HTTP 429.
- `RatioConnectionError` -- network/timeout failure.
- `RatioError` -- common base.

All public methods raise `RatioApiError("client is closed")` after `close()` has been called. User-supplied path segments (serial numbers, user IDs, vehicle IDs) are percent-encoded to prevent path traversal.

Token storage:

- `JsonFileTokenStore(path)` — atomic writes, mode 0o600.
- `MemoryTokenStore()` — for tests / ephemeral CLI use.
- `TokenStore` (ABC) — implement `load()` / `save()` / `clear()` to plug into other backends (e.g. HA's `Store` helper).

## Optional BLE support (`aioratio[ble]`)

The charger speaks Inspiro IPC (null-byte-delimited JSON) over a single BLE
GATT service. `aioratio.BleClient` is the optional local-control counterpart
to the cloud `RatioClient`.

```bash
pip install aioratio[ble]
```

Cloud-only installs do **not** pull in `bleak`; the BLE subpackage is
lazy-imported. Touching `aioratio.BleClient` without the `[ble]` extras
raises `RuntimeError` with an install hint.

### Quick start

```python
import asyncio
from aioratio import BleClient
from bleak import BleakScanner

async def main() -> None:
    device = await BleakScanner.find_device_by_address("AA:BB:CC:DD:EE:FF")
    async with BleClient(device) as client:
        info = await client.get_product_information()
        status = await client.get_charger_status()
        print(info.connectivity_controller.serial_number, status.indicators)

asyncio.run(main())
```

### Public BLE API

`BleClient` is an async context manager (`async with BleClient(device) as c:`).

Construction options:

- `BleClient(device)` — takes a `bleak.backends.device.BLEDevice`. Prefer this
  whenever you already have a `BLEDevice` in hand (e.g. any caller routing
  through a BLE proxy).
- `BleClient.from_address(address)` — convenience for scripts; scans for the
  device first.
- `BleClient.from_service_info(info)` — accepts anything carrying a `BLEDevice`
  on `.device` (duck-typed). Convenient when the caller is handed a
  discovery-record wrapper rather than the raw `BLEDevice`.

| Method | Returns | Notes |
|---|---|---|
| `connect()` / `disconnect()` | `None` | Manual lifecycle; prefer `async with`. |
| `get_product_information()` | `ProductInformationResponse` | Serial numbers, firmware/hardware versions. |
| `get_charger_status()` | `ChargerStatusResponse` | Cloud-connection state + charging indicators. |
| `get_charger_sensor_values()` | `ChargerSensorValuesResponse` | Live per-phase V/I. Voltages are deciV; use `voltage_phase_{1,2,3}_volts` / `current_phase_{1,2,3}_amps`. |
| `charge_control(control)` | `ChargeControlResponse` | `ChargeControl.START` / `STOP` / `PAUSE` / `RESUME`. |
| `get_user_settings()` / `set_user_settings(update)` | … | Get returns `SettableValue`-wrapped fields (`{value, isChangeAllowed, allowedValues?, lowerLimit?, upperLimit?}`); set takes a flat `UserSettingsUpdate`. |
| `get_solar_settings()` / `set_solar_settings(update)` | … | Same shape asymmetry. |
| `get_time_settings()` / `set_time_settings(update)` | … | Same shape asymmetry. |
| `get_network_status()` | `NetworkStatusResponse` | Wi-Fi / ethernet / IPv4 detail. SSID is base64 on the wire, decoded on `.ssid`. |
| `get_ocpp_status()` / `get_backend_status()` | … | `central_system` / `url` are base64 on the wire, decoded on the property. |
| `wifi_scan()` | `list[WifiAccessPoint]` | Triggers a scan and follows up with one `WifiAccessPointRequest` per AP. |
| `wifi_connect(ssid, password)` | `WifiConnectResponse` | SSID is base64-encoded for you. Password wire format is unverified — see warning. |

Each command is gated against the charger's reported Inspiro IPC protocol
version (read from the Version characteristic on connect); calls below the
minimum raise `RatioBleUnsupportedCommandError` before any wire write.

### Discovery

Chargers advertise with manufacturer ID `0x0BFF` (3071) and a local name
prefixed `RATIO_`. `parse_advertisement` accepts the same shape as
`bleak.backends.scanner.AdvertisementData` and returns `None` for non-Ratio
adverts:

```python
from aioratio.ble import parse_advertisement

adv = parse_advertisement(adv_data.local_name, adv_data.manufacturer_data)
if adv is not None:
    print(adv.local_name, hex(adv.manufacturer_byte))
```

Home Assistant's `BluetoothServiceInfoBleak` exposes `.name` (not
`.local_name`) and `.manufacturer_data` — use `parse_service_info` to skip
the field re-mapping in `async_step_bluetooth`:

```python
from aioratio.ble import parse_service_info

adv = parse_service_info(discovery_info)
```

Note: the advertised manufacturer byte is **not** the IPC protocol version.
One charger advertises `0x03` but reports `0x06` (BASELINE_4_0_0) on the
Version characteristic — read the characteristic for the authoritative value.

### Bonding and errors

The charger requires an SMP-bonded link before any GATT operation; the
Version characteristic read returns *Insufficient Authentication* until the
bond exists. Pairing uses a per-device PIN printed in the charger
documentation, so `BleClient` does not auto-bond — the caller is responsible
for bonding before `connect()` succeeds (BlueZ pairing agent on Linux, OS
pairing dialog on Windows / macOS).

When the GATT op fails for lack of a bond, `BleClient.connect()` raises
`RatioBleNotBondedError` (a `RatioBleError` subclass), distinct from the
generic `RatioBleConnectionError`, so callers can distinguish "needs pairing"
from "lost the link":

- `RatioBleError` — common base for the optional BLE client.
- `RatioBleConnectionError` — transport-level failure (scan, connect, GATT).
- `RatioBleNotBondedError` — peer rejected GATT for lack of a bond.
- `RatioBleProtocolError` — Inspiro IPC framing or response error.
- `RatioBleUnsupportedCommandError` — command below the charger's protocol
  version.

`scripts/ble_smoke.py` walks the priority reads against a real charger
(excluded from the wheel).

## Architecture

```
+-------------------+       +-----------------+       +------------+
|   RatioClient     |  -->  | _CloudTransport |  -->  | aiohttp    |
|  (public façade)  |       |  (private)      |       +------------+
+-------------------+       +-----------------+
        |                           |
        |                           v 401 retry once via auth
        v
+-------------------+       +-----------------+
| CognitoSrpAuth    |  -->  | TokenStore      |
| USER_SRP +        |       | (Memory/JSON)   |
| DEVICE_SRP +      |       +-----------------+
| REFRESH_TOKEN     |
+-------------------+
        |
        v
+-------------------+
| srp.py            |
| Cognito SRP-6a    |
+-------------------+
```

Files under `src/aioratio/`:

- `client.py` — public `RatioClient` async context manager.
- `_transport.py` -- private aiohttp transport. 401 -> invalidate_access_token -> retry once.
- `auth.py` -- `CognitoSrpAuth` driver: USER_SRP first-login, ConfirmDevice + UpdateDeviceStatus, REFRESH_TOKEN_AUTH (with rotation handling), DEVICE_SRP_AUTH second-login. Refresh serialised via `asyncio.Lock`. Accepts a `timeout` parameter (default 30s) for Cognito HTTP calls. Exposes `invalidate_access_token()` for callers to force a refresh on the next access.
- `srp.py` — pure-Python Cognito SRP-6a (3072-bit MODP, `Caldera Derived Key` HKDF, Java-style timestamp). Uses Java `BigInteger.toByteArray()` (`padHex`) semantics throughout — variable length with `0x00` sign byte when high bit set. Both `UserSrp` and `DeviceSrp` variants.
- `token_store.py` — `TokenBundle` dataclass + `TokenStore` ABC + `MemoryTokenStore` + `JsonFileTokenStore` (atomic write).
- `models/` — dataclasses derived from APK DTOs: `Charger`, `ChargerOverview`, `ChargerStatus`, `UserSettings`, `ChargeSchedule`, `SolarSettings`, `InstallerOcppSettings`, `CpmsConfig`, `ChargerDiagnostics`, `Session`, `SessionHistoryPage`, `Vehicle`, plus nested types. All have a `from_dict()` classmethod tolerant of unknown fields. `InstallerOcppSettings.to_dict()` emits the flat PUT shape; `ChargerDiagnostics` is read-only (no `to_dict`).
- `exceptions.py`, `const.py`.

## Cognito specifics (notes for maintainers and LLMs)

The Ratio user pool uses `USER_SRP_AUTH` with no client secret. Pool `eu-west-1_mH4sFjLoF`, client `78cs05mc0hc5ibqv1tui22n962`, region `eu-west-1`, API base `https://8q4y72fwo3.execute-api.eu-west-1.amazonaws.com/prod` — all centralised in `const.py`.

Confirmed-live behaviours:

- First login flow: `InitiateAuth(USER_SRP_AUTH)` → `RespondToAuthChallenge(PASSWORD_VERIFIER)` → tokens + `NewDeviceMetadata` → `ConfirmDevice` (with our generated device verifier) → `UpdateDeviceStatus(remembered)`.
- Subsequent login on a remembered device: PASSWORD_VERIFIER → `DEVICE_SRP_AUTH` → `DEVICE_PASSWORD_VERIFIER` → tokens.
- `REFRESH_TOKEN_AUTH` does **not** rotate the refresh token (server keeps the existing one; library handles either case).
- `compute_x` uses the format `poolName + username + ":" + password` (no colon between pool and user). All `salt`, `verifier`, `A`, `B`, `u`, `S` use Java `BigInteger.toByteArray` byte semantics, not fixed-length padding.
- `start-charge` requires `startCommandParameters` to always be present in the body (empty object is accepted; missing object is not).

## Development

```bash
git clone https://github.com/aaearon/aioratio
cd aioratio
uv venv --python 3.11        # or python3.11 -m venv .venv
.venv/bin/pip install -e '.[dev]'
.venv/bin/pytest -q
```

Tests are run with ``pytest``. Live smoke against a real account
lives in `scripts/smoke.py` (reads creds from `../.env`).

The SRP vector tests are intentionally self-consistent and do not catch
encoding bugs against Cognito — see commit `0283fb5` for two real bugs that
slipped past them. Live smoke is the source of truth.

## Status

Early. Used in production by [`home-assistant-ratio`](https://github.com/aaearon/home-assistant-ratio) against the **Ratio Solar** charger. Field nullability across some models is best-effort against the decompiled APK; flag mismatches as issues.

- **`set_solar_settings` HTTP 502** ([#9](https://github.com/aaearon/aioratio/issues/9)): Fixed. The cloud PUT endpoint expects flat nullable integers (`"sunOffDelayMinutes": 5`), not the nested value objects returned by GET (`{"value": 5, "isChangeAllowed": true, ...}`). `SolarSettings.to_dict()` now emits the correct PUT shape. Smoke-tested against the live API.
- `ScheduleSlot` and `ChargeSchedule` now have explicit `to_dict()` methods for controlled serialisation.
- `UpperLowerLimitSetting.to_dict()` now echoes back the full raw GET shape (used by `UserSettings.to_dict()`).

## License

MIT.

## Disclaimer

Unofficial. Reverse-engineered from the public Android APK and observed
Cognito traffic. Use at your own risk; the API is not contractually
stable. No affiliation with Ratio.
