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.

Download files

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

Source Distribution

usbairq-1.0.0.tar.gz (22.1 kB view details)

Uploaded Source

Built Distribution

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

usbairq-1.0.0-py3-none-any.whl (15.0 kB view details)

Uploaded Python 3

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

Hashes for usbairq-1.0.0.tar.gz
Algorithm Hash digest
SHA256 028f2e301a3496e3315a5cabf38c64a6810715317a4f7b65cbfe21b129b00b38
MD5 04e3eed0824f495df8bcbaf0f35a3f46
BLAKE2b-256 2b113a3bc404d2b3a57f716e0dda7c66eeabea74e6da7aafa0c1229a21c69d5d

See more details on using hashes here.

Provenance

The following attestation bundles were made for usbairq-1.0.0.tar.gz:

Publisher: publish.yml on CorantGmbH/usbairq

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

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

Hashes for usbairq-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 2e4c3324b9cf402a80083c1223704c443f6968881876afb0a8850a1c2ccc530a
MD5 8a195d3765abfaadf68b849f3100ea5a
BLAKE2b-256 83f63474856d2283851018d9574030e22f37cdc8c06cf59487fb38723d17f4b9

See more details on using hashes here.

Provenance

The following attestation bundles were made for usbairq-1.0.0-py3-none-any.whl:

Publisher: publish.yml on CorantGmbH/usbairq

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

Release history Release notifications | RSS feed

This release

1.0.0 This release

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