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 |
|
|
Close |
|
|
Interface accessor |
|
|
Operations |
|
|
|
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_measurementsonce 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:
the four target-memory methods —
SpiTarget.write_memory,SpiTarget.read_memory,I2cTarget.write_memoryandI2cTarget.read_memory.
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.writeand.readdid not exist before the bulk layer landed; the commit that created them created them as transport-negotiating methods, andAsyncSpiControlleralready existed carrying onlytransfer. 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 |
|---|---|---|
|
— |
yes |
|
— |
yes |
|
— |
yes |
|
— |
yes |
|
yes |
— |
|
yes |
— |
|
yes |
— |
|
yes |
— |
|
yes |
— |
|
yes |
— |
|
yes |
— |
|
yes |
— |
|
yes |
— |
|
yes |
— |
|
yes |
— |
|
yes |
— |
|
yes |
— |
|
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,readand the sixAsyncI3cControllertransfer 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.