Blocking and asyncio

Every protocol interface exists twice: a blocking class and an Async twin. The blocking API is the default — it is what test benches and scripts want. Reach for AsyncDevice when you need to overlap operations, or when the adapter is one of several things an event loop is already driving.

The two shapes, side by side

from pycosmicsdk import Device, I2cBus, I2cPullUp

with Device.open() as dev:
    i2c = dev.i2c(bus=I2cBus.A).initialize(
        frequency_hz=400_000, pull_up=I2cPullUp.OHM_330
    )
    data = i2c.read(address=0x50, length=16, subaddress=b"\x01\x00")
import asyncio
from pycosmicsdk import AsyncDevice, I2cBus, I2cPullUp

async def main() -> None:
    async with AsyncDevice.open() as dev:
        i2c = await dev.i2c(bus=I2cBus.A).initialize(
            frequency_hz=400_000, pull_up=I2cPullUp.OHM_330
        )
        data = await i2c.read(address=0x50, length=16, subaddress=b"\x01\x00")

asyncio.run(main())

Method names, argument names, argument order and return types are identical. The mechanical differences:

Blocking

asyncio

Open

with Device.open() as dev:

async with AsyncDevice.open() as dev: or dev = await AsyncDevice.open()

Close

dev.close()

await dev.close()

Interface accessor

dev.i2c(bus=...)

dev.i2c(bus=...) — not awaited; it does no I/O

Operations

i2c.read(...)

await i2c.read(...)

subscribe callback

a plain function

a coroutine function

open returns an object that is both awaitable and an async context manager, so await AsyncDevice.open() hands you a device you close yourself, while async with closes it on exit. Argument validation happens eagerly at the open() call; anything that needs the USB bus (no such model attached, for instance) surfaces on connect.

The accessors (i2c, i3c, and so on) are deliberately not coroutines — they only build and cache a controller object. Forgetting an await there is not a bug; forgetting one on an operation is, and the coroutine-never-awaited warning will tell you.

Overlapping work

This is the reason the async API exists. Independent operations on one device can be in flight at once, and the engine correlates each firmware response back to the coroutine that is waiting for it:

addresses, version, rails = await asyncio.gather(
    i2c.scan(),
    dev.get_version(),
    dev.get_analog_measurements(),
)

examples/async_api/i2c/concurrent_ops.py is the runnable version.

Notifications

Both APIs offer subscribe, returning a Subscription that detaches on unsubscribe() or on leaving a with block. The blocking form takes a plain callback; the async form takes a coroutine function.

The async API adds a pull-based alternative that the blocking API has no equivalent for — an async iterator, usable as an async context manager so the subscription is detached on every exit path including break and cancellation:

from pycosmicsdk import UartRxNotification

async with dev.iter_notifications(UartRxNotification) as stream:
    async for notification in stream:
        print(notification.data)

iter_receive is the UART-specific version of the same idea, with its own subscription and queue so it does not compete with receive or query for messages. See examples/async_api/uart/receive_stream.py.

Neither stream ends by itself. They yield until the handle goes terminal — close or reboot — and then the async for raises DisconnectError rather than stopping quietly, so a consumer running in its own task ends where the device did instead of falling through to the code that follows the loop. It is the same ending receive has always had, for the same reason. If a consumer task should finish cleanly when the device closes, say so:

async def consume(dev):
    try:
        async with dev.iter_notifications(UartRxNotification) as stream:
            async for notification in stream:
                handle(notification)
    except DisconnectError:
        pass  # the device closed under us; nothing more will arrive

Rail voltages read the same on both sides

Every voltage reader has an async twin with the same name, signature and return type: get_bus_voltage, get_bus_voltage, get_bus_voltage, get_bus_voltage (the two-rail exception, returning (hv_mv, lv_mv)) and get_voltage.

This page used to list them as blocking-only, on the grounds that CosmicAsyncSDK declares no method named *GetBusVoltage. That reading was wrong. It declares getAnalogMeasurements, which carries every rail in one transaction — and that is the very command each of upstream’s five blocking getters issues internally before slicing out one field. The async twins are built the same way, from the same command, and report the same numbers.

Two things follow:

  • Four of the five — I2C, SPI, UART and GPIO — read the same field, on both sides, because those four buses share one multifunction VTARG rail. Reading two of them is not a cross-check. See the lifecycle guide.

  • Each async reader costs a full six-rail read. If you want more than one of them, call get_analog_measurements once instead and read the fields you need off the result.

Where the two APIs are not mirror images

Four kinds of asymmetry are real, and the list below is the whole of it: every public member of Device and of all nine blocking interface classes was compared against its Async twin, and these are the differences that came out. Three of the four come from upstream CosmicSDK declaring a call on only one of its two pins (CosmicSDK blocking, CosmicAsyncSDK non-blocking) or from there being no equivalent quantity to report; the fourth — the bulk transfer plane — is much the largest, is a property of the transport rather than of either SDK’s surface, and accounts for thirteen of the eighteen rows in the summary table. None of them is an oversight in this SDK, and no amount of Python can paper over them.

The two pull-based streams — iter_notifications and iter_receive — are async-only too, but they are not in this list because they are not asymmetries in the same sense: they are async for shapes with no meaning on a blocking API, and the blocking side has the push equivalent (subscribe) and the pull equivalent (receive) already. See Notifications above.

get_analog_measurements is async-only

get_analog_measurements reads all six rails in one firmware transaction — the shared multifunction target rail, the I3C high- and low-voltage target rails, and the VCCA, VCCA-I3C and VDDIO2 supply rails — and it is declared on CosmicAsyncSDK only. It lives on the device rather than on an interface because no single protocol owns the reading.

So an AsyncDevice can read all six rails in one transaction and a blocking Device cannot. This is the narrow residue of what this page once described as a five-method asymmetry: upstream’s blocking per-protocol getters are themselves implemented on top of this same command, discarding the fields they were not asked for, and so are this SDK’s async ones, as the previous section describes. What the blocking side lacks is only the whole-block read.

If your loop is blocking and you need the full set, read the rails you care about one at a time; the three internal supply rails (VCCA, VCCA-I3C, VDDIO2) have no per-protocol reader at all and are async-only in the strict sense.

Device.discarded_response_count is blocking-only

discarded_response_count counts the replies this connection received and refused — a reply with no command waiting on it, one carrying another command’s transaction id, a duplicate, or one too short to hold a response header. It is a diagnostic, not an error indicator: on Windows every open handle on a HID device receives every input report, so a second process talking to the same adapter makes this climb on a perfectly healthy session. Read it when a command times out and the cause is unclear.

There is no AsyncDevice counterpart, and there should not be one. The counter lives on upstream’s BlockingApi — the layer that matches a reply to the command waiting for it — and CosmicAsyncSDK owns no BlockingApi and correlates nothing of the kind, so there is no equivalent quantity for an AsyncDeviceFacade to report. An async twin here could only fabricate a number or return a constant zero, and either would be worse than the absence: the whole value of this counter is that a non-zero reading is evidence.

So this one has no async workaround, and does not need one: an async command that times out is a matter for the engine’s own logging on the pycosmicsdk.aio logger. Do not reach for a second, blocking Device on the same adapter to read the count — a second client on one adapter is one of the things that makes the count rise in the first place, so the measurement would create what it measures.

I3cCcc.transfer is async-only

i3cCccTransfer — the raw “send this CCC opcode” primitive that the named CCC methods wrap — is on CosmicSDK’s async pin only. So AsyncI3cCcc has transfer and I3cCcc does not.

Practical consequence: the ~40 named CCCs (getpid, setmwl_direct, enec_broadcast, …) are available identically on both sides. An opcode this SDK does not name is reachable only from the async API.

Nothing that carries a payload over the bulk plane has an async twin

This is the largest of the four and the only one that is not upstream declaring a call on a single pin — the async calls exist, all of them — so it is worth being explicit that it is a decision. Do not “finish the surface”.

Thirteen members are absent from the async side for this one reason, and they are of two different kinds: six payload-carrying methods, which are the rule itself, and seven capability properties, which are a much weaker consequence of it — a property that would answer identically on both surfaces was simply not written.

This said fourteen and eight until 2026-09-05, and the extra one was max_hdr_ddr_transfer_bytes, listed here in error. It does not describe the bulk route: its body is a constant, it needs no negotiation, reads no capability, produces no device traffic, and answers 1024 on both transports. So the reason given for its absence never applied to it, and leaving it off cost the async caller the one number whose stated purpose is to stop them wedging their bus. It now exists on AsyncI3cController and is not an asymmetry at all.

The six payload-carrying methods:

And the seven accessors that exist only to describe a route the async side cannot take: SpiController.transport, I3cController.transport, the max_transfer_bytes on each of SpiController, I3cController and I2cController, and the two target interfaces’ max_transfer_bytes.

Note

This thirteen is not upstream’s fourteen. The two were conflated while the numbers coincided, and they no longer do — but the conflation is worth keeping named, because the sentence that caused it (“fourteen payload-carrying methods have no async twin”) travels without saying whose fourteen. The thirteen described here are this package’s L3 members: public members present on a blocking interface class and absent from its async twin. Upstream’s fourteen are the CosmicAsyncSDK methods whose data rides the vendor bulk endpoints, and every one of those does have an async twin in the C++ header. What they have no twin for is this repository’s AsyncDeviceFacade, one layer down from anything on this page. So the sentence is false of either set taken alone: upstream’s fourteen all have async twins upstream, and of this package’s thirteen only six carry a payload.

Two further methods exist on both sides but differ in their signature, which no table of absences can show: write and read take no transport argument, and neither do the six AsyncI3cController private-transfer methods. All eight stay on the ordinary route, at its 1024-byte ceiling.

The reason is structural, and it has two independent legs.

A bulk write pushes its payload from inside the submit. CosmicAsyncSDK’s bulk calls run the payload send within the submit itself, before any reply, against a two-second per-phase budget. Every *_async submit in this package is required to be non-blocking; that is the whole contract of the async facade and what lets an awaiting coroutine yield rather than pin a thread. A submit that can stall for two seconds would stall the event loop itself and every other coroutine on it — worse than not offering the method.

A bulk read cannot be correlated, only isolated. The bulk plane is a single unframed byte stream shared by every bulk operation on the connection: a reply announces a length on the HID path, the bytes then arrive on the bulk endpoint with no transaction id, no header and no delimiter, and the transport drains whatever is left once the announced count has been collected. The only thing associating a payload with the command that asked for it is that nothing else was in flight. So one bulk transfer owns the connection from submit to drain, and an async def holding a connection-exclusive lock across its own await is a blocking operation with async def in front of it — advertising concurrency the transport cannot provide. An executor does not rescue it either: it would serialise the collections, and the damage is done by the submits overlapping.

Those two legs are why no payload crosses the async surface. They do not, on their own, explain why SpiController’s pair is absent while I2C’s and I3C’s shipped.

Note

Two different questions, not one rule applied twice — stated plainly, because this page used to assert both halves. The reason recorded on AsyncSpiController for withholding SPI’s write/read is that mirroring them over the HID path alone “would be worse still: they are transport-negotiating, so an async twin that could never reach the bulk plane would silently mean something different from its blocking namesake.” Read as a general rule that is contradicted by three of its own protocols, since AsyncI2cController.write/.read and the six AsyncI3cController transfer methods ship in exactly that shape — HID-only, 1024 bytes. It is not a general rule, and the transport did not force the difference either: SpiController.write has a real HID route (it falls back to a 1024-byte spi_transfer and discards MISO, identical on the wire to what the bulk write clocks out), so a HID-only async twin was constructible by the same means the I3C ones use.

What actually differs is which question was being asked, and the repository records that in fragments. The two protocols reached their async shape from opposite directions:

  • SPI’s pair was new. SpiController.write and .read did not exist before the bulk layer landed; the commit that created them created them as transport-negotiating methods, and AsyncSpiController already existed carrying only transfer. The question was therefore whether to add a narrowed twin for a brand-new method whose whole reason to exist was to reach the bulk plane. It was declined.

  • I2C’s pair and I3C’s six already shipped on both surfaces. Bulk arrived later and added transport= to the blocking side only. The question was therefore whether to retract eight working async methods so that the two surfaces would keep matching signatures. That was declined too.

So one decision is about adding a method and the other is about removing one, and “present but HID-only” is only the name they share. The repository already argues this exact distinction for the sibling AUTO question, at I2cTransport: I2C’s write and read are “shipped methods with existing callers, unlike SpiController.write which was new when its AUTO was designed.” The same asymmetry decides the async shape.

Which half of the wording survives. “Silently” does not, and for I2C it is doubly wrong: I2cTransport.AUTO is size-dependent, so for every payload the async twin accepts the blocking method would have chosen the ordinary route anyway — the twin is not a narrowed method but the same one with its ceiling made explicit, and above that ceiling it raises ValidationError naming the limit, which is the opposite of silent. For I3C the ceiling genuinely does narrow, but only for the SDR pair: HDR-DDR is capped at I3C_HDR_DDR_MAX_PAYLOAD_BYTES on both routes and gains nothing from bulk, and legacy I2C is bounded by its own bus rate. What survives for SPI is not silence but purpose: a pair created to negotiate transport, whose non-negotiating twin would be a different method wearing the same name.

One reading to rule out, because it is plausible and wrong. The AUTO asymmetry is not the explanation. I2cTransport.AUTO is the only one that consults the request length; both SpiTransport.AUTO and I3cTransport.AUTO prefer the bulk plane purely as a function of the connection. That axis splits I2C from SPI and I3C, while the async surface splits I2C and I3C from SPI — the two are orthogonal, so AUTO cannot be what drove it. It only reinforces the I2C half.

Nothing unsafe ships either way. The full archaeology, including the commits, is docs_dev/designs/async-surface-divergence-provenance.md.

If an asyncio surface for the SPI pair is wanted later, the honest shape is loop.run_in_executor around the blocking call.

The argument in full is docs_dev/designs/async-bulk-transfer-shape.md; the user-facing version is The plane is blocking-only.

Everything that does not carry a payload is mirrored normally. transfer is a full mirror of transfer, as is the whole rail surface; the async target classes carry their blocking twin’s entire configuration surface, plus release, arena_size and wait_for_event on both — so an async program can arm a target, learn how big its arena is, and watch its bus events; and AsyncI3cTarget keeps write_memory and read_memory, because the I3C target’s arena is a private HID-carried buffer and never touches the bulk plane at all. See the SPI guide, Target mode and The bulk transfer plane.

Warning

One adapter has one bulk plane, claimed exclusively by whichever handle opened it first. So a Device and an AsyncDevice on the same adapter cannot both use it, and opening a second blocking handle to do the arena traffic for an async target on that unit does not work. Choose one handle style per adapter.

A handle that stops answering is refused on both surfaces

AdapterNotAnsweringError is not a surface difference — it is the same guard, with the same limit, on both. Three commands in a row on one handle timing out with nothing answering in between, and the fourth is not sent. The errors reference carries the whole argument; what belongs here is the part that is specific to await.

Commands overlap on this surface, and the count is built for that. Four unanswered commands issued together through asyncio.gather trip the guard exactly as four issued one after another do — each is counted, and each is timed separately. A command you cancel counts for nothing in either direction: cancelling says nothing about whether the adapter would have replied, so it neither accumulates towards the limit nor clears what has accumulated. The same is true of anything that takes no traffic to the wire, AsyncDevice.get_version included, which stays readable after the guard has tripped.

The internals differ, and only in a way worth knowing if you are reading the source. Device observes how each command ended on its lock, which it holds across the command; AsyncDevice releases its lock before awaiting, so it observes the outcome in AsyncDevice._async_call instead — the one place every correlated asyncio command is submitted and awaited, which tests/unit/test_concurrency_hazards.py holds statically so a method added later cannot quietly issue outside it.

Warning

As on the blocking surface, this guard cannot un-wedge a device and is not a proof against wedging one. It stops this SDK adding to a run of unanswered commands; it arrives too late for a device that has already stopped answering, and firmware has at least one path to a mute HID interface that a single abandoned transfer can reach, which no host-side count prevents. Treat it as a diagnosis worth acting on, not as a guarantee.

Summary of the surface differences

Member

Blocking

asyncio

Device.get_analog_measurements

—

yes

Device.iter_notifications

—

yes

Uart.iter_receive

—

yes

I3cCcc.transfer

—

yes

Device.discarded_response_count

yes

—

SpiController.write

yes

—

SpiController.read

yes

—

SpiController.transport

yes

—

SpiController.max_transfer_bytes

yes

—

I3cController.transport

yes

—

I3cController.max_transfer_bytes

yes

—

I2cController.max_transfer_bytes

yes

—

SpiTarget.write_memory

yes

—

SpiTarget.read_memory

yes

—

SpiTarget.max_transfer_bytes

yes

—

I2cTarget.write_memory

yes

—

I2cTarget.read_memory

yes

—

I2cTarget.max_transfer_bytes

yes

—

That is the complete list of member-level differences. One thing the table cannot carry needs saying:

The eight transport= parameters are not absences, and so are not rows.

write, read and the six AsyncI3cController transfer methods exist with the same names and one fewer keyword argument. A table of members cannot show that, so it is stated here instead.

Everything else — all five voltage readers, all of I3cTarget, every CCC wrapper, every Gpio and I2cController member, all six bring_up methods, every target-mode configuration call, and the whole Device lifecycle and USB-config surface — is present on both, with the same names and the same signatures bar the return type, which names the twin (I2cController.initialize returns an I2cController, its twin an AsyncI2cController).

Two differences that are not absences, and so are not in the table: AsyncDevice.open returns an object that is both awaitable and an async context manager rather than a device, and AsyncDevice.subscribe takes a coroutine function where the blocking one takes a plain callable. Both are in the mechanical-differences table at the top of this page.

tests/unit/test_docs_examples_drift.py checks this table against the real classes: a — where the member exists on that twin, or a yes where it does not, fails there. The check exists because this table once carried five rows claiming the voltage readers were blocking-only, and went on carrying them after the async twins landed — every name in those rows was real, so no name-based check could see it.