PyPI package usbairq
Python library for asynchronous data access to local air-Q devices over USB, without any network.
usbairq is aioairq with a different
transport: USBAirQ inherits from AirQ, so the API, the encryption and the
returned data are identical — only the bytes travel over the device's serial port
instead of HTTP.
Requirements
- An air-Q with a USB port. Ask the device:
GET /configreports it as"usb": true. At the moment this is the air-Q Radon Science. - A firmware version that serves the serial API.
- On the PC: no driver installation on current Windows, macOS and Linux.
Usage
import asyncio
from usbairq import USBAirQ
PASSWORD = "airqsetup"
async def main():
ports = USBAirQ.discover() # e.g. ["/dev/ttyUSB0"]
async with await USBAirQ.connect(ports[0], PASSWORD) as airq:
config = await airq.get_config()
print(f"Available sensors: {config['sensors']}")
data = await airq.get_latest_data()
print(f"Averaged data: {data}")
await airq.set_device_name("Living room")
asyncio.run(main())
USBAirQ.discover() lists all connected FTDI bridges — the serial equivalent of
mDNS. The chip is not exclusive to the air-Q, so treat the entries as candidates:
connect() verifies the password and raises InvalidAuth or TimeoutError if
the port belongs to something else.
Every method of aioairq.AirQ is
available: get_config, get_latest_data, get_log, fetch_device_info,
blink, get_night_mode / set_night_mode, get_led_theme / set_led_theme,
brightness, restart, shutdown, …
How long a call takes
Almost all of it is the device thinking, not the link, so these numbers hold for the local HTTP API too:
| Call | Time |
|---|---|
get_latest_data() |
~40 ms |
get_config(), fetch_device_info() |
~260 ms — a large payload to serialise and encrypt |
set_device_name() and other config writes |
~5.5 s — the device persists to flash |
Config writes return None, so a script that awaits one looks stuck for those
five seconds with nothing to show. Print something before the call if that
matters. A device in its first minute after boot is slower across the board,
because sensor warm-up, Wi-Fi and the SD mount are all competing.
Historical data
The device stores its measurements on the SD card as year/month/day/timestamp.
Walk the tree and download a day, exactly as over WLAN:
import asyncio
from usbairq import USBAirQ
PASSWORD = "airqsetup"
async def main():
async with await USBAirQ.connect(USBAirQ.discover()[0], PASSWORD) as airq:
for year in sorted(await airq.get_historical_files_list()):
for month in sorted(await airq.get_historical_files_list(year), key=int):
for day in sorted(await airq.get_historical_files_list(f"{year}/{month}"), key=int):
for stamp in await airq.get_historical_files_list(f"{year}/{month}/{day}"):
path = f"{year}/{month}/{day}/{stamp}"
records = await airq.get_historical_file(path)
print(f"{path}: {len(records)} measurements")
asyncio.run(main())
examples/download_history.py is the runnable
version of this, with a --csv option that writes every measurement to one file.
get_historical_file() prefers the device's compressed mirror and falls back to
the plain file when it is missing. compressed=False forces the plain route. If
the device password was changed after the data was written, pass recrypt=True:
the device then re-encrypts its cleartext mirror with the current password.
Measured on an air-Q Radon Science at 921600 baud, for one full 48 KB day file:
| Route | Bytes | Time |
|---|---|---|
/file_zlib (compressed=True, the default) |
8320 | 0.35 s |
/file (compressed=False) |
49573 | 1.25 s |
/file_recrypt (recrypt=True) |
49573 | 2.19 s |
So the compressed mirror is worth roughly a factor of three and a half, and using
it is the default. Two caveats, both firmware-side: the file currently being written
has no mirror yet, and neither does the last file of each day — the device
compresses a file only when it hits its 48 KB limit and a new one is started, so a
day's final file stays uncompressed for good. Both simply take the /file fallback.
Throughput is about 38 kB/s on the plain route. From firmware 2.3.0 on that is device-side work — SD read, per-line encryption, framing — not the link; older firmware is capped near 10 kB/s by its scheduler tick regardless of the baud rate. Fetching a whole day over USB now costs about what it costs over WLAN. A download can still stall for a moment when it collides with the measurement loop writing to the SD card, since both contend for the same card lock.
Console speed
The console runs at 921600 baud from firmware 2.3.0 on, which is the default
here. There is no command to change it: the rate is compiled into the firmware
(MICROPY_HW_UART_REPL_BAUD), because the port that carries the API also carries
the boot log, and a device that answers at an unexpected rate is a device nobody
can talk to. For older firmware pass baudrate=115200 to connect().
Damaged lines
A serial link has no checksum, so a flipped byte is caught by what sits on top of
it: the device answers 400 when it cannot decrypt the request, 404 or 405
when the path or method it received is not the one that was sent, and nothing at
all when the ##AQ1 prefix itself was hit. A damaged response breaks the JSON or
the AES padding.
Every request is therefore sent up to RETRY_ATTEMPTS times, a broken chunk
stream restarts the whole download, and only the last attempt's failure reaches
the caller. Errors the device means — a 500, or the 404 from a missing
/file_zlib mirror — are passed straight on rather than retried.
Not available over USB
from_device_id()— USB devices are addressed by port, not by mDNS name.
Protocol
Newline-delimited JSON frames with a magic prefix, in both directions:
##AQ1 {"path": "/data", "method": "GET"}
##AQ1 {"id": "<DeviceID>", "status": 200, "content": "<base64(iv || aes)>"}
Lines without the ##AQ1 prefix — boot loader output, firmware logs — are
discarded by both sides. content is Base64 of a 16-byte IV followed by the
AES-256-CBC ciphertext, keyed with the device password padded to 32 bytes, i.e.
exactly what the local HTTP API returns. Error responses carry a plaintext
error and an HTTP-style status instead, surfaced as usbairq.DeviceError.
Requests are answered strictly one at a time; usbairq serialises concurrent
calls internally. Request frames are capped at 4096 bytes.
A response too large for one frame — the file routes — arrives as a numbered
chunk stream, closed by a frame carrying eof:
##AQ1 {"path": "/file", "method": "GET", "request": "<base64(iv || aes)>"}
##AQ1 {"id": "<DeviceID>", "status": 200, "seq": 0, "chunk": "…", "eof": false}
##AQ1 {"id": "<DeviceID>", "status": 200, "seq": 1, "chunk": "", "eof": true}
Chunks are not encrypted a second time: what the file routes serve is already
encrypted on the SD card. A gap in seq raises InvalidAirQResponse.
Development
git clone https://github.com/CorantGmbH/usbairq
cd usbairq
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
# The unit tests speak the protocol to a fake device and need no hardware
pytest
# With a device attached, the on-device tests are enabled by AIRQ_PORT
AIRQ_PORT=/dev/ttyUSB0 AIRQ_PASS=12345678 pytest
On Linux, access to /dev/ttyUSB0 usually requires membership in the dialout
group (sudo usermod -aG dialout $USER, then log in again).
This repository uses pre-commit for linting and
formatting with Ruff. Install the git hooks
once with pre-commit install.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file usbairq-1.0.0.tar.gz.
File metadata
- Download URL: usbairq-1.0.0.tar.gz
- Upload date:
- Size: 22.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
028f2e301a3496e3315a5cabf38c64a6810715317a4f7b65cbfe21b129b00b38
|
|
| MD5 |
04e3eed0824f495df8bcbaf0f35a3f46
|
|
| BLAKE2b-256 |
2b113a3bc404d2b3a57f716e0dda7c66eeabea74e6da7aafa0c1229a21c69d5d
|
Provenance
The following attestation bundles were made for usbairq-1.0.0.tar.gz:
Publisher:
publish.yml on CorantGmbH/usbairq
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
usbairq-1.0.0.tar.gz -
Subject digest:
028f2e301a3496e3315a5cabf38c64a6810715317a4f7b65cbfe21b129b00b38 - Sigstore transparency entry: 2424209996
- Sigstore integration time:
-
Permalink:
CorantGmbH/usbairq@405105b5a5b0bc96d011cadcf86f588e83b8ffc0 -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/CorantGmbH
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@405105b5a5b0bc96d011cadcf86f588e83b8ffc0 -
Trigger Event:
release
-
Statement type:
File details
Details for the file usbairq-1.0.0-py3-none-any.whl.
File metadata
- Download URL: usbairq-1.0.0-py3-none-any.whl
- Upload date:
- Size: 15.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2e4c3324b9cf402a80083c1223704c443f6968881876afb0a8850a1c2ccc530a
|
|
| MD5 |
8a195d3765abfaadf68b849f3100ea5a
|
|
| BLAKE2b-256 |
83f63474856d2283851018d9574030e22f37cdc8c06cf59487fb38723d17f4b9
|
Provenance
The following attestation bundles were made for usbairq-1.0.0-py3-none-any.whl:
Publisher:
publish.yml on CorantGmbH/usbairq
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
usbairq-1.0.0-py3-none-any.whl -
Subject digest:
2e4c3324b9cf402a80083c1223704c443f6968881876afb0a8850a1c2ccc530a - Sigstore transparency entry: 2424210262
- Sigstore integration time:
-
Permalink:
CorantGmbH/usbairq@405105b5a5b0bc96d011cadcf86f588e83b8ffc0 -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/CorantGmbH
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@405105b5a5b0bc96d011cadcf86f588e83b8ffc0 -
Trigger Event:
release
-
Statement type: