Skip to main content

aioratio

Async Python client for the Ratio EV Charging cloud API.

PyPI Python License: MIT

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

pip install aioratio

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

Quick start

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 a sparse UserSettingsUpdate (recommended — sends only the keys you set), a UserSettings dataclass, or a pre-formed camelCase dict.
charge_schedule(serial) ChargeSchedule
set_charge_schedule(serial, schedule) None Takes a sparse ChargeScheduleUpdate (sends only the keys you set) or a pre-formed camelCase dict. Passing the ChargeSchedule read model raises TypeError.
solar_settings(serial) SolarSettings
set_solar_settings(serial, settings) None Accepts a sparse SolarSettingsUpdate (recommended), a SolarSettings dataclass, 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 a sparse OcppSettingsUpdate (recommended), 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.

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

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:

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:

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.pyTokenBundle 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 and ChargeSchedule are read-only (no to_dict). Writes should go through the sparse *Update DTOs.
  • 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 + NewDeviceMetadataConfirmDevice (with our generated device verifier) → UpdateDeviceStatus(remembered).
  • Subsequent login on a remembered device: PASSWORD_VERIFIER → DEVICE_SRP_AUTHDEVICE_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

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 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): 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 has an explicit to_dict() for controlled serialisation. ChargeSchedule.to_dict() was removed (#28) — its bool fields could not express "leave unchanged", so every PUT disabled the schedule, reset randomizedTimeOffsetEnabled and overwrote the stored week plan. Use ChargeScheduleUpdate; set_charge_schedule() now rejects the read model with TypeError.
  • set_user_settings HTTP 400 (#25): Fixed. SetUserSettings declares five optional, nullable fields — two Int? and three String? — so UserSettings.to_dict() now emits bare scalars ("cableSettings": "LockAutomatically") instead of echoing the GET value objects, which the API rejected with Changing setting "cableSettings" ... is out of range. Use UserSettingsUpdate to write a single key.
  • UpperLowerLimitSetting.to_dict() echoes back the full raw GET shape for round-tripping; it is no longer used to build PUT bodies.
  • Sparse update DTOs (#29): UserSettingsUpdate, SolarSettingsUpdate, OcppSettingsUpdate and ChargeScheduleUpdate each mirror their Set*$$serializer.java element list, emit only the fields you set, and are the supported way to write settings without re-asserting cached values.
  • Vehicle.to_dict() omits None (#27): all four Vehicle$$serializer elements are optional and the app writes under explicitNulls=false, so add_vehicle() no longer posts "vehicleId": null, "vehicleState": null.

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.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

aioratio-0.12.0.tar.gz (107.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

aioratio-0.12.0-py3-none-any.whl (69.0 kB view details)

Uploaded Python 3

File details

Details for the file aioratio-0.12.0.tar.gz.

File metadata

  • Download URL: aioratio-0.12.0.tar.gz
  • Upload date:
  • Size: 107.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for aioratio-0.12.0.tar.gz
Algorithm Hash digest
SHA256 cc5ec5e058657c089ddb499a1f58d725d4a7f04646e3b4401b269f3870296782
MD5 bcc426bba8fbd69c8727bfaea4ce4e21
BLAKE2b-256 61b1207776014c1aec9fcd5614351df32383dc4231aad78ab7e88a8acd994567

See more details on using hashes here.

Provenance

The following attestation bundles were made for aioratio-0.12.0.tar.gz:

Publisher: publish.yml on aaearon/aioratio

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file aioratio-0.12.0-py3-none-any.whl.

File metadata

  • Download URL: aioratio-0.12.0-py3-none-any.whl
  • Upload date:
  • Size: 69.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for aioratio-0.12.0-py3-none-any.whl
Algorithm Hash digest
SHA256 6e008c49cf4d97b32a2951f24f3731cb50aafdc79e950a9e72722ba74b093af9
MD5 6ec80c4a7d9e6dcfb17b52621cb8054b
BLAKE2b-256 a2411d0aca148e3c6e7a62064149fdc534bb4cdf19105ee1d70ad1cf737f3b93

See more details on using hashes here.

Provenance

The following attestation bundles were made for aioratio-0.12.0-py3-none-any.whl:

Publisher: publish.yml on aaearon/aioratio

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.13.0

2 files

This release

0.12.0 This release

2 files

0.11.0

2 files

0.10.1

2 files

0.10.0

2 files

0.9.1

2 files

0.9.0

2 files

0.8.0

2 files

0.7.0

2 files

0.6.0

2 files

0.5.0

2 files

0.4.0

2 files

0.3.0

2 files

0.2.0

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page