Migrating to robonomics-interface 3.0¶
3.0 is a rewrite. It keeps the wire formats — the same address from the same
mnemonic, the same ciphertext, the same extrinsic bytes — and changes the API.
This guide is written for moving the Robonomics Report Service projects
(rrs-connector, rrs-admin, rrs-ha-integration) off robonomics-interface
2.x, substrate-interface and the copied chain/ packages, but applies to any
2.x user.
Pin the exact version everywhere: robonomics-interface==3.0.0.
The API listed in section 8 is the same as in the release candidates.
1. What changed, and why¶
2.x |
3.0 |
|
|---|---|---|
Dependencies |
|
|
Keys |
sr25519 by default, ed25519 optional |
ED25519 by default; sr25519 with the |
Connection |
hidden, per object, synchronous, no request timeout |
explicit |
Types |
hand-written type registry |
the chain’s own metadata |
Writes |
returns a hash; failure inside the block goes unnoticed |
reads the block’s events; a failed call raises |
Errors |
whatever the transport library raised |
one hierarchy under |
2. Dependencies¶
Remove substrate-interface, websocket-client and any copy of the chain/
package. Add robonomics-interface==3.0.0. It brings pynacl, scalecodec,
websockets and xxhash; do not list them separately unless the project uses
them directly.
Home Assistant: the manifest requirements becomes ["robonomics-interface==3.0.0", ...]
(plus the project’s own, e.g. pinatapy-vourhey). HA already constrains
websockets>=15, which 3.0 accepts. No aiohttp session is needed: the library
has its own WebSocket client.
3. The client¶
Async (Home Assistant, any asyncio code)¶
from robonomicsinterface import RobonomicsClient
client = RobonomicsClient(endpoints) # str or list, in order of preference
await client.connect() # or: async with RobonomicsClient(...) as client:
...
await client.close()
Create one client and keep it: it holds one multiplexed connection and the
parsed runtime metadata (parsing takes seconds on a Raspberry Pi; the library
does it off the event loop). In an integration, create it per config entry and
close it on unload (entry.async_on_unload(client.close)).
In Home Assistant, pass HA’s shared TLS context:
RobonomicsClient(endpoints, ssl=homeassistant.util.ssl.client_context()). It honours
HA’s CA settings and is built once for all integrations. Without ssl=, the library
builds its own default context once per client, off the event loop (since rc2; rc1
built one per connection on the loop, which HA reported as a blocking call).
Blocking (scripts, cron jobs, CLIs)¶
from robonomicsinterface import RobonomicsSync
with RobonomicsSync(endpoints) as client:
client.datalog.latest(address)
Same methods and arguments without await; query_map is a plain iterator.
It must not be called from a thread running an event loop — it raises
RuntimeError there, because blocking would stall that loop.
Options that replace 2.x workarounds¶
Need |
3.0 |
|---|---|
Per-request timeout (was: |
|
Several nodes, move on when one fails (was: hand-written |
pass them all: |
Know which node is in use |
|
Kusama |
Legacy: Robonomics on Kusama is shutting down — switch to Polkadot. The default genesis check refuses a Kusama node and says so; with |
A node on the local network |
put it first: |
4. Errors¶
RobonomicsError
├── TransportError .retryable = True: the node was not reached or stopped answering
│ ├── ConnectionFailed (.endpoint, .reason)
│ ├── ConnectionLost
│ ├── RequestTimeout (also a TimeoutError)
│ └── AllEndpointsFailed (.failures: one ConnectionFailed per endpoint)
├── RpcError the node answered with an error (.code, .message, .data)
├── TransactionError an extrinsic did not do what was asked
│ ├── InvalidTransaction refused before sending (.kind, .explanation)
│ ├── ExtrinsicFailed in a block, call failed (.pallet, .error, .docs, .result)
│ ├── ExtrinsicDropped dropped by the node
│ └── ExtrinsicOutcomeUnknown sent, outcome unknown (.extrinsic_hash) — do not resend blindly
├── MetadataError / NoSuchPallet / NoSuchStorage / NoSuchCall / NoSuchConstant / UnsupportedExtension
├── EncodeError / DecodeError (ValueError) e.g. TooManyDevices is an EncodeError
├── InvalidAddress / InvalidKey / InvalidMnemonic (ValueError)
├── NoSecretKey, DecryptionError
└── EnvelopeError: RecipientError, PackageError (ValueError), PayloadError
Replace TRANSIENT_ERRORS = (OSError, TimeoutError, WebSocketException) and
RETRYABLE_ERRORS = (RpcError, ChainError, TimeoutError, OSError) with
TransportError. The client already retries reads across endpoints, so an
outer retry loop only needs to cover a whole run, if at all. RpcError is not a
network failure and repeating the request will not help.
5. API mapping¶
Keys and addresses¶
2.x / substrate-interface / |
3.0 |
|---|---|
|
|
|
|
|
|
|
|
|
the |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Encryption¶
Before |
3.0 |
|---|---|
|
same names, same bytes; failures raise |
|
|
|
|
|
|
|
|
Reading the chain¶
Before |
3.0 (async; |
|---|---|
|
|
|
|
|
|
walking |
|
|
|
|
|
|
|
|
|
anything else: |
|
|
|
Writing¶
Before |
3.0 |
|---|---|
|
|
|
same as above |
|
|
read devices, edit, |
|
|
|
any other call |
|
Every write returns an ExtrinsicResult (.block_hash, .block_number,
.index, .events, .find(pallet, name)) or raises. Options for all writes:
wait_for="in_block"|"finalized", tip, era_period (64; None for immortal),
validate (ask the runtime first; on by default), timeout (120 s).
6. Pitfalls — behaviour that changed silently in meaning¶
item(a, 0)is slot 0. In 2.xget_item(index=0)meant “latest”. The connector worked around it through_service_functions; that code goes.Only live slots are returned. The ring buffer keeps at most
WindowSize - 1(127) records; the slot atendmay still hold an overwritten record in storage.item()returnsNonefor it, where a raw storage read returned the stale record.Records are
bytes. scalecodec returned a record as text when it was printable and as0x…hex otherwise, sostr(content)could silently turn a CID into hex. Useitem.text(UTF-8 orNone) oritem.data. When writing, astris sent as UTF-8 —"0x12"stays the four characters it is.is_valid_address(a)checks for Robonomics (format 32) by default.is_valid_ss58_address(a)accepted any network. Passss58_format=Noneto keep the old behaviour, or keep the default to reject5…addresses.Keypair.from_addressanddecode_addressaccept any format unless given one.set_devicestakes a flat list and replaces the whole list. The[[a, b]]wrapping and the decode-back guard inrrs-admingo. Addresses of any SS58 format are accepted, normalised to format 32 and de-duplicated; more thanMaxDevicesAmount(32) raisesTooManyDevicesbefore anything is sent.add_devices/remove_devicesread, edit and write — two concurrent edits of one owner can lose one.A failed call raises. Inclusion in a block is not success. Catch
ExtrinsicFailedand look at.pallet/.error:RWS.NotLinkedDevice— the signer is not in the owner’s device list;RWS.FreeWeightIsNotEnough— no allowance left: an expired daily subscription stays on chain and fails this way (not withNoSubscription), and so does a fresh one before it has accrued;RWS.NoSubscription— the owner has none.
InvalidTransactionwith.kind == "Payment"means the signer does not exist on chain (balance below the existential deposit, 0.000001 XRT), even for free RWS calls.client.system.exists(a)tells beforehand. It is true when the account has providers or sufficients — not thefree or noncetestrrs-adminused, which misses reserved-only accounts.ExtrinsicOutcomeUnknownis not a failure. The extrinsic was sent and may still land; look it up by.extrinsic_hashbefore sending again.Mortal era by default. Extrinsics are valid for ~64 blocks, so a stuck one cannot land an hour later, nor be replayed if the account is ever reaped.
Keypair.from_secretrefuses derivation paths (//Alice); useKeypair.from_uriin tests.Keypair.sign(str)signs UTF-8; the 2.x stack decoded astrstarting with0xas hex first.Keypairs keep secrets to themselves:
repr()shows the address, errors never include the input, pickling raisesTypeError.ED25519 is the default key type.
Keypair.from_mnemonic(m)gives the ED25519 address, as report encryption required in 2.x. A mnemonic that was used with sr25519 gives a different address unless you saycrypto_type="sr25519"(3.1, section 10).
7. Per project¶
rrs-connector¶
pyproject.toml: dropsubstrate-interfaceandwebsocket-client; replacerobonomics-interface>=2.0.0with==3.0.0rc1.robonomics/datalog_reader.py: rebuild onRobonomicsSync, keepingDatalogRecord,DatalogIndexRange,DatalogScanand the cursor logic (list_new_records,list_last_records) as they are:class DatalogReader: def __init__(self, wss_endpoints, request_timeout_seconds, max_attempts=1, backoff_seconds=0): if not wss_endpoints: raise ValueError("At least one WSS endpoint is required") self.client = RobonomicsSync( list(wss_endpoints), timeout=request_timeout_seconds, retries=max(max_attempts - 1, 0), ).connect() def get_window_size(self) -> int: return self.client.datalog.window_size() def get_index_range(self, sender_address): index = self.client.datalog.index(sender_address) return DatalogIndexRange(index.start, index.end) def get_item(self, sender_address, datalog_index): item = self.client.datalog.item(sender_address, datalog_index) if item is None or item.text is None: return None return DatalogRecord(sender_address, item.index, item.timestamp_ms, payload=item.text)
_interface,_reconnect, the socket timeout and the directchainstate_queryall go.ring_buffer_indicescan stay (it matchesDatalogIndex.positions) or be replaced;client.datalog.items(a)reads all records in one request, which is cheaper than oneget_itemper slot. Add aclose()and call it at the end of a run.robonomics/retry.py:TRANSIENT_ERRORS = (TransportError,); with the client retrying across endpoints,before_retry=_reconnectis no longer needed.keygen.py:mnemonic = generate_mnemonic(),Keypair.from_mnemonic(...),.address. The read-back check stays.config.py:is_valid_address(address, ss58_format=None)to keep accepting any format — or the default, to require Robonomics addresses (pitfall 4).proton_pass.py:load_integrator_accountreturnsKeypair.from_secret(seed)and compares.address.pipeline.py,reports/recipients.py: theAccounttype becomesKeypair(AccountLoader = Callable[[str], Keypair]).reports/decryptor.py:multi_envelope_decrypt_databecomes a thin wrapper overdecrypt_package(package, recipient_keypair, sender_address)that mapsPackageError/PayloadErrortoReportDecryptionError;decrypt_msgand thesubstrateinterfaceimports go.parse_decryptedcan come from the library.Tests:
tests/test_decryptor.py,test_keygen.py,test_retry.py,conftest.pyimportsubstrateinterface; switch them to the library. Keep any fixture ciphertext produced by the old stack: it is the compatibility check.
rrs-admin¶
pyproject.toml: dropsubstrate-interface; addrobonomics-interface==3.0.0rc1.Delete
src/rrs_admin/chain/(the third copy of the integration’s key code); import fromrobonomicsinterface:Keypair.create_from_mnemonic→Keypair.from_mnemonic,create_from_secret→from_secret,.ss58_address→.address,ss58_decode→decode_address,SS58Error→InvalidAddress,validate_mnemonicunchanged,MnemonicError→InvalidMnemonic. Files:pools.py,sites.py.rws.py:def read_subscription(url, pool) -> Subscription: with RobonomicsSync(url) as client: devices = client.rws.devices(pool) ledger = client.rws.ledger(pool) account = client.system.account(pool) ... # Subscription(kind=ledger.kind, issued=ledger.issued_at, # expires=ledger.expires_at, free_weight=ledger.free_weight, # balance=account.free / XRT) def account_exists(url, address) -> bool: with RobonomicsSync(url) as client: return client.system.exists(address) def set_devices(url, pool_seed, devices) -> str: REDACT.add(pool_seed) pool = Keypair.from_mnemonic(pool_seed) planned = list(dict.fromkeys(encode_address(decode_address(d)) for d in devices)) with RobonomicsSync(url) as client: try: result = client.rws.set_devices(pool, planned) except TransactionError as e: raise ChainError(REDACT(f"set_devices failed: {e}")) from e if client.rws.devices(pool) != planned: raise ChainError("the chain holds a different device list than planned") return result.block_hash
_addresses_inand the[devices]wrapping go (pitfall 5). Reading the list back after inclusion is a stronger check than decoding the call before signing.Subscription.kindchanges from"Daily 30 days"formatting done by hand toledger.kind/ledger.days.
rrs-ha-integration¶
manifest.jsonrequirements:["robonomics-interface==3.0.0rc1", "pinatapy-vourhey==0.1.9"].Delete
custom_components/robonomics_report_service/chain/and its tests (tests/test_chain.py,tests/test_chain_client.py): the library carries them, byte-for-byte vectors included. Keeptests/fixtures/substrate_vectors.jsonin a test that runs the library against it, as the project’s own contract.robonomics.py:one
RobonomicsClient(POLKADOT_ENDPOINTS, ssl=client_context())(fromhomeassistant.util.ssl) per config entry, created inasync_setup_entry, closed on unload; remove_clients,current_wss,change_current_wssand the endpoint loop in_send_datalog;publishing:
await client.datalog.record(self.sender_keypair, data, subscription_owner=self._owner_address or self.sender_address);errors:
ExtrinsicFailedandInvalidTransaction→ the integration’sRobonomicsErrorwith the library’s message (it already explainsPayment,NotLinkedDeviceandFreeWeightIsNotEnough);TransportError→ retry later;ExtrinsicOutcomeUnknown→ do not unpin the files from Pinata yet, the record may have landed.Kusama is legacy and shutting down: drop
NETWORK_KUSAMAfromconst.pyand the network choice from the config flow, and move existing Kusama entries to Polkadot — or, until the network stops, keep the option labelled “legacy — switch to Polkadot” and create its client withgenesis_hash=None.
config_flow.py:Keypair.from_secret,generate_mnemonic(),decode_address(raisesInvalidAddress, aValueError, so the existingexcept ValueErrorstill works).utils/encrypt_tools.py,utils/file_handler.py: importKeypair,encrypt_for_recipients,decrypt_package,parse_decryptedand the envelope errors fromrobonomicsinterface;encrypt_message/decrypt_messagewrappers becomekeypair.encrypt_message(...)+ hex.Nothing may block the event loop: use
RobonomicsClientonly, neverRobonomicsSync(which refuses to run there anyway).
8. Stable API¶
The Report Service projects build on the names below. They do not change between
release candidates and 3.0.0; if one ever has to, the change is written here, and
tests/test_stable_api.py — which pins this list — changes in the same commit.
RobonomicsClient(endpoints, genesis_hash=, ssl=, timeout=, retries=),connect(),close(),async with;RobonomicsSync, the same withoutawait.client.datalog.items,.record(..., subscription_owner=),.window_size;client.rws.devices,.ledger,.set_devices;client.system.account,.exists.Keypair.from_secret,.from_mnemonic,.address,.public_key,.verify,.encrypt_message,.decrypt_message; since 3.1 alsoED25519,SR25519,Keypair.from_json,Keypair.crypto_typeand the keyword-onlycrypto_type=offrom_seed,from_mnemonic,from_uri,from_secret(default ED25519);generate_mnemonic,is_valid_address,decode_address,encode_address,address_format.encrypt_for_recipients,decrypt_package,parse_decrypted.DatalogItem(index, timestamp_ms, data, text),Ledger(kind, days, issued_at, expires_at, free_weight),AccountInfo(free, exists),XRT,ROBONOMICS_GENESIS_HASH.Errors:
TransportError(ConnectionLost,RequestTimeout,ConnectionFailed,AllEndpointsFailed),RpcError(method, code, message),TransactionError,ExtrinsicFailed(pallet, error),ExtrinsicOutcomeUnknown(extrinsic_hash),ExtrinsicDropped,InvalidTransaction(kind, explanation),RecipientError,EnvelopeError.
Changes since rc1: none to this list. rc2 adds the TLS fix, block hash checks and the Kusama legacy handling; 3.1 only adds names (see CHANGELOG).
9. sr25519 (3.1)¶
Optional: existing objects stay on ED25519; new ones may be created on sr25519, the
default of Polkadot wallets. Install robonomics-interface[sr25519]==3.1.0 (wheels for
musl and aarch64, no compiler); without the extra everything else works as in 3.0.
from robonomicsinterface import SR25519, Keypair
Keypair.from_mnemonic(m) # ED25519, as before
Keypair.from_mnemonic(m, crypto_type=SR25519) # or "sr25519", e.g. from a config
Keypair.from_json(backup_json, password) # polkadot.js wallet backup; type from the file
Where the type lives. In the object’s settings (the HA config entry, the connector’s senders and integrators), next to the secret. Nothing in an address tells it, and a wrong guess is not always caught (
PayloadErrornames the mismatch when opening a package). Existing entries without the field mean ED25519.One type per package. The sender and all recipients share a type; the package has the sender’s. A mixed recipient list raises
RecipientError. Keep the integrators of an sr25519 object on sr25519 keys.Signing and datalog work the same: the extrinsic carries the signature variant of the key’s type. The account must exist on chain (fund it), as for ED25519.
Encryption is ECIES, compatible with
py-sr25519-encryptionand SubMessage, so a browser can decrypt with polkadot.js. It does not authenticate the sender; the connector trusts the datalog author (its senders registry), not the cipher.from_jsonneeds the backup password and runs scrypt (128 MiB; seconds on a Raspberry Pi): call it in a thread, not on the event loop.
10. Checklist¶
[ ] No
substrateinterface,websocket,robonomicsinterface.Accountorchain.import left (grep -rn "substrateinterface\|from websocket\|Account\b\|\.chain" src).[ ] The same mnemonic gives the same address as before (the project’s fixtures, or
substrate_vectors.json).[ ] A report encrypted by the old stack still decrypts, and a new one opens with the connector.
[ ] Datalog slot 0 reads as slot 0; a wrapped buffer reads 127 records, oldest first.
[ ]
set_deviceswith a flat list, read back from the chain.[ ] A missing account raises
InvalidTransactionwithkind == "Payment"; a device outside the list raisesExtrinsicFailedRWS.NotLinkedDevice.[ ] Home Assistant: the integration installs in
python:3.13-alpineon aarch64 and HA logs no “Detected blocking call”.[ ] sr25519 (3.1), if used: the key type is a setting of the object; a report from an sr25519 site opens with an sr25519 integrator key, and the connector reads it by the datalog author.
[ ] Integration tests against a development node, if the project has any:
scripts/devchain.shin this repository starts one; fund your test accounts, ED25519 or sr25519, by a transfer from the sr25519//Alice.