aioratio
Async Python client for the Ratio EV Charging cloud API.
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) — implementload()/save()/clear()to plug into other backends (e.g. HA'sStorehelper).
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 ableak.backends.device.BLEDevice. Prefer this whenever you already have aBLEDevicein 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 aBLEDeviceon.device(duck-typed). Convenient when the caller is handed a discovery-record wrapper rather than the rawBLEDevice.
| 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— publicRatioClientasync context manager._transport.py-- private aiohttp transport. 401 -> invalidate_access_token -> retry once.auth.py--CognitoSrpAuthdriver: USER_SRP first-login, ConfirmDevice + UpdateDeviceStatus, REFRESH_TOKEN_AUTH (with rotation handling), DEVICE_SRP_AUTH second-login. Refresh serialised viaasyncio.Lock. Accepts atimeoutparameter (default 30s) for Cognito HTTP calls. Exposesinvalidate_access_token()for callers to force a refresh on the next access.srp.py— pure-Python Cognito SRP-6a (3072-bit MODP,Caldera Derived KeyHKDF, Java-style timestamp). Uses JavaBigInteger.toByteArray()(padHex) semantics throughout — variable length with0x00sign byte when high bit set. BothUserSrpandDeviceSrpvariants.token_store.py—TokenBundledataclass +TokenStoreABC +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 afrom_dict()classmethod tolerant of unknown fields.InstallerOcppSettings.to_dict()emits the flat PUT shape;ChargerDiagnosticsandChargeScheduleare read-only (noto_dict). Writes should go through the sparse*UpdateDTOs.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_AUTHdoes not rotate the refresh token (server keeps the existing one; library handles either case).compute_xuses the formatpoolName + username + ":" + password(no colon between pool and user). Allsalt,verifier,A,B,u,Suse JavaBigInteger.toByteArraybyte semantics, not fixed-length padding.start-chargerequiresstartCommandParametersto 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_settingsHTTP 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.ScheduleSlothas an explicitto_dict()for controlled serialisation.ChargeSchedule.to_dict()was removed (#28) — itsboolfields could not express "leave unchanged", so every PUT disabled the schedule, resetrandomizedTimeOffsetEnabledand overwrote the stored week plan. UseChargeScheduleUpdate;set_charge_schedule()now rejects the read model withTypeError.set_user_settingsHTTP 400 (#25): Fixed.SetUserSettingsdeclares five optional, nullable fields — twoInt?and threeString?— soUserSettings.to_dict()now emits bare scalars ("cableSettings": "LockAutomatically") instead of echoing the GET value objects, which the API rejected withChanging setting "cableSettings" ... is out of range. UseUserSettingsUpdateto 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,OcppSettingsUpdateandChargeScheduleUpdateeach mirror theirSet*$$serializer.javaelement list, emit only the fields you set, and are the supported way to write settings without re-asserting cached values. Vehicle.to_dict()omitsNone(#27): all fourVehicle$$serializerelements are optional and the app writes underexplicitNulls=false, soadd_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.
Metadata
Release files for aioratio 0.13.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| aioratio-0.13.0.tar.gz | 108.6 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| aioratio-0.13.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 177.8 kB
Release files / aioratio-0.13.0.tar.gz
| Download URL | aioratio-0.13.0.tar.gz |
|---|---|
| Size | 108.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
a87a9ec4a47e29bdd89af6a8aaafd99547144c211c4af41218fdd0f07a5a5bba
|
|
BLAKE2b-256 checksum How to use checksums |
ab4ac5298b289e73b89759168dbb711683722fab7f20240fcb7efeec7004bce3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 25, 2026.
Transparency logRelease files / aioratio-0.13.0-py3-none-any.whl
| Download URL | aioratio-0.13.0-py3-none-any.whl |
|---|---|
| Size | 69.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c6605a708ded7eb0c8269b9f52902dabebb36066a8fe2a1e3032a2f1dd23831b
|
|
BLAKE2b-256 checksum How to use checksums |
0fdfdb2e9ef97473411c4a02e60ac88f9b2374c09352dbc3d3018b90f84c72f2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 25, 2026.
Transparency logSecurity for aioratio 0.13.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for aioratio-0.13.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 25, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.