# 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 {py:obj}`~pycosmicsdk.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 ```python 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") ``` ```python 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 | {py:obj}`~pycosmicsdk.AsyncDevice.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 ({py:obj}`~pycosmicsdk.AsyncDevice.i2c`, {py:obj}`~pycosmicsdk.AsyncDevice.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: ```python 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 {py:obj}`~pycosmicsdk.Device.subscribe`, returning a {py:obj}`~pycosmicsdk.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: ```python from pycosmicsdk import UartRxNotification async with dev.iter_notifications(UartRxNotification) as stream: async for notification in stream: print(notification.data) ``` {py:obj}`~pycosmicsdk.AsyncUart.iter_receive` is the UART-specific version of the same idea, with its own subscription and queue so it does not compete with {py:obj}`~pycosmicsdk.AsyncUart.receive` or {py:obj}`~pycosmicsdk.AsyncUart.query` for messages. See `examples/async_api/uart/receive_stream.py`. **Neither stream ends by itself.** They yield until the handle goes terminal — {py:obj}`~pycosmicsdk.AsyncDevice.close` or {py:obj}`~pycosmicsdk.AsyncDevice.reboot` — and then the `async for` raises {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.AsyncUart.receive` has always had, for the same reason. If a consumer task should finish cleanly when the device closes, say so: ```python 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: {py:obj}`~pycosmicsdk.AsyncI2cController.get_bus_voltage`, {py:obj}`~pycosmicsdk.AsyncUart.get_bus_voltage`, {py:obj}`~pycosmicsdk.AsyncSpiController.get_bus_voltage`, {py:obj}`~pycosmicsdk.AsyncI3cController.get_bus_voltage` (the two-rail exception, returning `(hv_mv, lv_mv)`) and {py:obj}`~pycosmicsdk.AsyncGpio.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](guides/lifecycle.md). - Each async reader costs a full six-rail read. If you want more than one of them, call {py:obj}`~pycosmicsdk.AsyncDevice.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 {py:obj}`~pycosmicsdk.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 — {py:obj}`~pycosmicsdk.AsyncDevice.iter_notifications` and {py:obj}`~pycosmicsdk.AsyncUart.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 ({py:obj}`~pycosmicsdk.Device.subscribe`) and the pull equivalent ({py:obj}`~pycosmicsdk.Uart.receive`) already. See **Notifications** above. ### `get_analog_measurements` is async-only {py:obj}`~pycosmicsdk.AsyncDevice.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 {py:obj}`~pycosmicsdk.AsyncDevice` can read all six rails in one transaction and a blocking {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.Device.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 {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.AsyncI3cCcc` has {py:obj}`~pycosmicsdk.AsyncI3cCcc.transfer` and {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.I3cController.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 {py:obj}`~pycosmicsdk.AsyncI3cController` and is not an asymmetry at all. The six payload-carrying methods: - {py:obj}`~pycosmicsdk.SpiController.write` and {py:obj}`~pycosmicsdk.SpiController.read`, the transport-negotiating half-duplex pair; - the four target-memory methods — {py:obj}`SpiTarget.write_memory `, {py:obj}`SpiTarget.read_memory `, {py:obj}`I2cTarget.write_memory ` and {py:obj}`I2cTarget.read_memory `. And the seven accessors that exist only to *describe* a route the async side cannot take: {py:obj}`SpiController.transport `, {py:obj}`I3cController.transport `, the `max_transfer_bytes` on each of {py:obj}`~pycosmicsdk.SpiController`, {py:obj}`~pycosmicsdk.I3cController` and {py:obj}`~pycosmicsdk.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: {py:obj}`~pycosmicsdk.AsyncI2cController.write` and {py:obj}`~pycosmicsdk.AsyncI2cController.read` take no `transport` argument, and neither do the six {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.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: {py:obj}`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 {py:obj}`~pycosmicsdk.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](guides/bulk.md). Everything that does **not** carry a payload is mirrored normally. {py:obj}`~pycosmicsdk.AsyncSpiController.transfer` is a full mirror of {py:obj}`~pycosmicsdk.SpiController.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 {py:obj}`~pycosmicsdk.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](guides/spi.md), [Target mode](guides/target-mode.md) and [The bulk transfer plane](guides/bulk.md). :::{warning} **One adapter has one bulk plane**, claimed exclusively by whichever handle opened it first. So a {py:obj}`~pycosmicsdk.Device` and an {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.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](reference/errors.md) 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, {py:obj}`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. : {py:obj}`~pycosmicsdk.AsyncI2cController.write`, {py:obj}`~pycosmicsdk.AsyncI2cController.read` and the six {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.I3cTarget`, every CCC wrapper, every {py:obj}`~pycosmicsdk.Gpio` and {py:obj}`~pycosmicsdk.I2cController` member, all six `bring_up` methods, every target-mode configuration call, and the whole {py:obj}`~pycosmicsdk.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: {py:obj}`AsyncDevice.open ` returns an object that is both awaitable and an async context manager rather than a device, and {py:obj}`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.