Skip to main content

PyPI version license

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 /config reports 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.

Metadata

Release files for usbairq 1.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for usbairq 1.0.0
File Size Uploaded Provenance
usbairq-1.0.0.tar.gz 22.1 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for usbairq 1.0.0
File Interpreter ABI Platform
usbairq-1.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 37.1 kB

Release files / usbairq-1.0.0.tar.gz

Download URL usbairq-1.0.0.tar.gz
Size 22.1 kB
Tags Source
SHA-256 checksum
How to use checksums
028f2e301a3496e3315a5cabf38c64a6810715317a4f7b65cbfe21b129b00b38
BLAKE2b-256 checksum
How to use checksums
2b113a3bc404d2b3a57f716e0dda7c66eeabea74e6da7aafa0c1229a21c69d5d
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 11, 2026.

Transparency log

Release files / usbairq-1.0.0-py3-none-any.whl

Download URL usbairq-1.0.0-py3-none-any.whl
Size 15.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
2e4c3324b9cf402a80083c1223704c443f6968881876afb0a8850a1c2ccc530a
BLAKE2b-256 checksum
How to use checksums
83f63474856d2283851018d9574030e22f37cdc8c06cf59487fb38723d17f4b9
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 11, 2026.

Transparency log

Security for usbairq 1.0.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.

Select from 2 files

Attestation for usbairq-1.0.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 11, 2026.

Transparency log

A 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.

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release files Full provenance

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