The device lifecycle

Every protocol guide starts the same way — with Device.open() as dev: — and then talks about a bus. This page is about the handle itself: how you find an adapter, how you open it, what states it can be in, what you can ask it before you ask the bus anything, and the two calls that end a handle for good.

Nothing here is protocol-specific. It applies identically to Device and AsyncDevice; where the two differ, the difference is called out and the full account is in Blocking and asyncio.

Discovery

list_devices answers “what is plugged in?” before there is a device to ask. It reads USB-HID enumeration directly, so no adapter is opened and no firmware command is sent — a listing costs nothing on the wire and disturbs nothing that is already running.

from pycosmicsdk import DeviceModel, list_devices

for found in list_devices():
    print(f"{found.model.name} {found.serial_number} hw {found.hw_version} fw {found.fw_version}")

supernovas = list_devices(model=DeviceModel.SUPERNOVA)

With nothing attached it returns an empty list rather than raising, so a script can report “no adapter” itself instead of catching.

Each entry is a DiscoveredDevice, and every field on it is ready to use as an open() argument or as a filter key:

Field

What it is

model

The DeviceModel, taken from the USB product id — the authoritative model marker at enumeration time, not a guess from a product string.

serial_number

Untagged serial, ready to pass as open(serial=...). Empty if the adapter reports none.

path

USB-HID device path, ready to pass as open(path=...). Valid only until the adapter is replugged.

manufacturer, product_name

The USB strings, untagged.

hw_version, fw_version

Decoded from the USB release number: "B" and "4.5.0".

Important

DiscoveredDevice is deliberately not DeviceInfo, and the two are not interchangeable. DeviceInfo is what an open adapter says about itself, reported raw, so its strings carry the firmware’s three-character tags — serial_number="SN-634F…", hw_version="HW-B". Only the untagged form is accepted by open(serial=...); passing the tagged one raises DeviceNotFoundError. So round-trip a serial through list_devices, never through dev.info.

Only adapters this SDK can actually open are listed — selection is by the (vendor id, product id) pairs the CosmicSDK driver accepts — which is what makes model= a pre-open filter rather than an open-then-check. Entries are grouped by model in DeviceModel declaration order and, within a model, follow the host’s HID enumeration order. Neither ordering is stable across replugs, and neither predicts which adapter an argument-less Device.open() picks. One physical adapter exposing several HID interfaces is collapsed to a single entry by serial number.

examples/blocking_api/system/list_devices.py is the runnable version, and the one place in the example suite that walks the raw listing.

Opening

Device.open() takes three selectors, and they are mutually exclusive — passing more than one raises ValidationError before anything touches USB. They choose differently, and that is the whole basis for picking one:

serial="634F…"

A specific physical unit, identified by something that survives a replug. What you want when the bench has two of the same model and the scripts must not swap them. Use the untagged serial from list_devices.

path="…"

A specific unit as it is attached right now. The path is a host-side enumeration artefact and goes stale when the adapter is replugged, so it is for the span of one session — most naturally, an entry you just got back from list_devices.

model=DeviceModel.SUPERNOVA

The first enumerated adapter of that model. The right selector when the scenario needs a particular model but any unit of it will do — an I3C script, say, which a Pulsar cannot run at all. Most of the examples in this repository use it for exactly that reason. Which unit it lands on when several of that model are attached is not defined.

(no selector)

The first enumerated adapter of any supported model. Fine for a one-adapter bench; a coin flip otherwise.

timeout_ms is the fourth argument and is not a selector: it is the per-call firmware response timeout applied to commands on the resulting handle, defaulting to 2000 ms to match CosmicSDK. It does not bound how long open() itself takes.

It does, however, apply to the identity query open() issues before it hands the handle back, and that gives it a practical floor. Fetching the device identity is several USB round trips and costs roughly 30 ms on a healthy adapter, so a very small timeout_ms cannot open at all: 250 attempts each at timeout_ms=1 and timeout_ms=4 failed every time, inside the identity fetch, with SDK_ERROR_TIMEOUT. Tightening this value is for bounding a later command; leave it at the default unless you have measured what your commands actually need, and keep it comfortably above the tens of milliseconds an open costs.

The context-manager form

open() returns a handle you must eventually close. Prefer the context manager, on both APIs:

from pycosmicsdk import AsyncDevice, Device, DeviceModel

with Device.open(model=DeviceModel.SUPERNOVA) as dev:
    print(dev.info.serial_number)
async def main() -> None:
    async with AsyncDevice.open(model=DeviceModel.SUPERNOVA) as dev:
        print(dev.info.serial_number)

AsyncDevice.open() returns an object that is both awaitable and an async context manager, so dev = await AsyncDevice.open() also works and hands you a device you close yourself. Argument validation happens eagerly at the call; anything needing the USB bus surfaces on connect.

The explicit form — dev = Device.open(...) then dev.close(), ideally in a finally — is fully supported and is what examples/blocking_api/system/get_device_info_simple.py shows. close is idempotent, so a close() in a finally after a with block is harmless rather than an error.

When the open fails

DeviceNotFoundError is raised when no handle could be established, and it distinguishes the two cases that need opposite responses from you. Either nothing matched the criteria — check the cable, the selector, the udev rules on Linux — or an adapter is attached and is not answering, because its firmware is mid-way through a long operation such as a slow UART send. The message says which, and in the second case says that the adapter becomes openable again on its own.

Note

open() retries internally for the busy case, so you should not. An adapter mid-way through a long firmware operation is briefly un-openable, and a single attempt is scheduled — by its own cost — to land inside that window. So a failed attempt that was itself slow, which is the signature of an adapter that is present but not answering, is retried within a short budget before open() gives up. A fast failure means nothing matched, and is reported immediately rather than waited on.

The selector changes the cost, not the outcome: path= and model= fail fast against a busy adapter, while serial= has to open the device to read its serial and so pays the slow attempt before it gives up.

The state machine

state is a DeviceState, and it is public so you can detect a stale handle without issuing a call that raises. is_connected is the boolean shorthand for state is DeviceState.OPEN.

State

How you get there

What still works

OPEN

open() returned

everything

TERMINAL

reboot, or a set_usb_config that got no firmware answer

state, info, model, capabilities, close()

CLOSED

close(), or leaving the with block

state, info, model, capabilities, close() again

NEW is declared for completeness; a handle returned by open() is already OPEN and never reports it.

What makes a handle TERMINAL. reboot(), which makes the adapter re-enumerate over USB, and a set_usb_config() that times out or disconnects, after which the adapter’s state is unknown. A reboot means the operating-system handle underneath your Device now refers to a device that has gone away. Nothing in Python can repair that, so the SDK stops pretending: the transition happens as part of the call, and every subsequent operation raises DisconnectError rather than failing somewhere further down.

That is also true of a caller parked in a blocking receive at the moment of the transition — it is woken and raises DisconnectError instead of hanging until its timeout.

Reconnecting means opening a new Device. There is no reopen(), and this is not an omission: the identity, the capability snapshot and the native handle are all bound to one connection, so a handle that survived a re-enumeration would be lying about at least one of them. Wait for the adapter to come back, then open() again.

How long that takes, measured. On a Pulsar (HW-C, FW-4.5.0) a reboot(ResetTarget.RUNTIME) returned in 0.3 ms, the adapter left the USB bus within 6 ms, was back in list_devices() 0.765 s later, and opened 0.060 s after that — 0.825 s from the call to an openable adapter. This page used to say “a few seconds”, which was CosmicSDK’s own recommendation rather than anything measured here. The measurement is one observation on one adapter: poll list_devices() in a loop rather than sleeping 0.825 s.

from pycosmicsdk import Device, DeviceModel, ResetTarget

with Device.open(model=DeviceModel.SUPERNOVA) as dev:
    dev.reboot(into=ResetTarget.RUNTIME)
    # dev.state is DeviceState.TERMINAL from here; dev is finished.

# ... allow the adapter to re-enumerate, then open a fresh handle.
with Device.open(model=DeviceModel.SUPERNOVA) as dev:
    print(dev.info.serial_number)

Still close a TERMINAL handle — leaving the with block does it — because the host-side resources it holds are not released by the adapter’s departure.

Capabilities

DeviceCapabilities is captured once at open() from the model and hardware revision the firmware reports — there is no capabilities query on the wire to issue, which is exactly why the mapping lives in this SDK. It is a snapshot, not a live query, and reading it costs no USB traffic at all.

from pycosmicsdk import Device, I2cBus, ProtocolInterface

with Device.open() as dev:
    caps = dev.capabilities
    print(ProtocolInterface.I3C in caps.protocols)
    print(I2cBus.B in caps.i2c_buses)
    print(caps.max_i2c_payload_bytes)

The point of the record is to let you ask in advance instead of discovering a limit by failing. Four of its fields sit in front of a guard that refuses the call with CapabilityError before anything is sent: i2c_runtime_reconfigurable, supports_i2c_pull_up(pull_up), supports_gpio_interrupt(pin) and supports_spi_bulk(). The full table of what each one guards and why it can be False is Capabilities you can ask about in advance.

Three of those four are table-backed: they are derived at open() from the model and hardware revision, using tables that live in this SDK, so they are answered from memory. bulk is the odd one out — it is runtime-queried and host-dependent, negotiated lazily with the adapter on first SPI use and reading as un-negotiated until then, and it can differ for the same adapter on two different PCs because the bulk plane needs a vendor USB interface claimed on this machine. It is a BulkTransportCapability, which reports host_transport_open and device_supported separately because they are different fixes.

Whole protocols are gated the same way: asking a Pulsar for Device.i3c() raises CapabilityError immediately rather than failing on the wire.

The tables behind those four fields fail open. They are this SDK’s own transcription of conditions in the adapter’s firmware, and a transcription can be out of date; refusing something that would have worked is the failure a user cannot work around, so a model this release does not gate and an hw_version that does not parse as a revision are both attempted rather than refused. The one exception is the per-model pull-up set, which enumerates what firmware accepts rather than what it rejects. Whole protocols are the other exception, and a sharper one: an adapter whose model this release does not recognise reports no protocols at all, so every accessor raises and the handle is good only for reading info. The reasoning for each is in Refusals fail open.

Important

CapabilityError derives from CosmicError, not from FirmwareError, so except FirmwareError does not catch it. And because nothing was sent, it carries StatusCode.SDK_ERROR_WRONG_REQUEST rather than a firmware rejection code — so a handler that catches it must give it its own clause and must not filter that clause on status_code. See Capability refusals arrive before the wire.

Device info: cached versus fresh

There are two ways to ask an adapter who it is, and they answer different questions.

info

The DeviceInfo snapshot taken at open(). A property, no I/O, and stable for the lifetime of the handle — including after close(). That last part is the reason it exists: a teardown log line or an error message assembled after the device is gone still has an identity to print.

fetch_device_info

Issues a fresh get_device_info query and returns the result. Requires the handle to be OPEN, and raises DisconnectError otherwise.

The natural next question is which one is right when they could disagree — and they cannot. Every field is fixed for the life of a connection, because the only command that changes firmware-side identity state, reboot(), re-enumerates the adapter and leaves this handle TERMINAL. A saved set_usb_config() waits for that reset too. The changed information has to be read from a newly opened device either way. So fetch_device_info() is a fresh read of the same facts — a liveness check, or a way to see what the firmware reports right now — and not a change detector.

model is the convenience accessor for info.model.

Four version numbers are in play, and no two of them are the same thing. A bug report is much easier to act on with all four, and they come from four different places:

Where

What it is

pycosmicsdk.__version__

This Python package. A plain str, read from the installed distribution metadata at import — a source checkout that was never installed reports "0.0.0+unknown".

get_version

The CosmicSDK library — the C/C++ layer running on your PC, compiled into the wheel. Not the adapter’s.

info.fw_version

The firmware running on the adapter, e.g. "FW-4.5.0" — tagged, like the two rows around it. The untagged "4.5.0" is fw_version, which is decoded from the USB release number rather than read from a descriptor.

info.hw_version

The adapter’s board revision, e.g. "HW-B". The one that decides several capability answers.

Remember the tagged strings: info.serial_number reads "SN-634F…", and that form is not accepted by open(serial=...). The untagged form a caller can actually reopen with comes from list_devices — see Discovery above.

examples/blocking_api/system/get_device_info.py prints both forms side by side.

Rebooting

reboot takes a ResetTarget and replaces what used to be three separate calls. Every target is terminal for the handle.

RUNTIME

A normal device reset. The adapter restarts its firmware session and comes back as itself, ready to be reopened — and its peripherals come back un-initialised. That second half is what makes it useful rather than merely drastic: it is the clean way out of a bus left in a state you would rather not reason about, and it is the reason a re-run after a reboot behaves like a first run. (Which is also why bring_up() exists — see Bring-up is bring_up() — so that not having rebooted is fine too.)

What it clears, and what it does not, measured on 2026-09-05. It clears a latched I2C controller role, on both buses — which matters because a role otherwise survives close() and answers FW_INTERFACE_ALREADY_INITIALIZED to the next handle’s initialize(). It does not clear the bus VTARG setpoint: a rail set to 3000 mV read 2986 mV before the reset and 2984 mV after, so the peripherals on the bus stay powered across it. Whether it clears the I2C pull-up setting is unknown — nothing reads that value back. One bench session on one Pulsar; treat each as a first observation rather than a contract.

BOOT

A different matter entirely, and not a heavier RUNTIME. The adapter jumps straight into its bootloader, and it stays there: upstream’s own contract is that a new connection can be made after the user has flashed a firmware, not merely after waiting. So this is a step in a firmware-flashing procedure, and reaching for it because a bus looked stuck leaves you with an adapter that needs flashing before it is a host adapter again. RUNTIME is the reset you want for that.

ISP

Reserved by CosmicSDK and not implemented. Raises NotImplementedError.

USB configuration

The adapter stores a small USB interface configuration in flash: get_usb_config reads it and set_usb_config writes it, both carrying a UsbConfig with three fields.

webusb_enabled

A WebusbState — DISABLED, ENABLED, or the write-only sentinel NO_CHANGE, which leaves the field as it is. A read never returns the sentinel.

cdc_enabled

A CdcState, with the same three members and the same write-only meaning for NO_CHANGE.

cdc_mode

A CdcMode — which protocol the CDC interface bridges. This enum has no no-change sentinel, so every write applies a mode. Some members are model-specific, each needing a transceiver the other model does not fit: TERMINAL is Supernova-only, and RS485 and CANFD are Pulsar-only. Asking for a mode this model cannot run raises CapabilityError before any facade call.

The read has one failure worth naming in advance, because it is not the one you would guess. A reply get_usb_config cannot decode raises ProtocolError — not a FirmwareError — and that covers two cases: a cdc_mode value newer than this SDK, and, more likely in practice, firmware that predates the command altogether. Upstream maps a header-only firmware error response to SDK_ERROR_WRONG_RESPONSE, so “your adapter’s firmware is too old for this” and “the answer did not parse” are the same exception here. See ProtocolError means the answer did not parse.

Because cdc_mode always applies, the only safe shape is read-modify-write — never compose a UsbConfig from scratch:

import dataclasses

from pycosmicsdk import CapabilityError, CdcMode, Device

with Device.open() as dev:
    current = dev.get_usb_config()
    try:
        dev.set_usb_config(dataclasses.replace(current, cdc_mode=CdcMode.RS485))
    except CapabilityError:
        # Refused before anything reached the adapter, so nothing was written
        # and the handle is still OPEN. Deliberately NOT status-filtered: the
        # check raises SDK_ERROR_WRONG_REQUEST, not a firmware rejection code.
        print(f"{dev.model} has no transceiver for that CDC mode")

Warning

set_usb_config is flash-persistent. On success the firmware writes the configuration to flash and replies; it does not reset. The handle stays OPEN and get_usb_config() reads back the saved values, but the adapter keeps its current USB interfaces until the next reset or power cycle — call reboot() to apply it now. The change survives power cycles and outlives your process. It is the one call in this SDK that can leave an adapter in a state the next person has to undo. Read the current configuration, change one field, and mean it.

A firmware-reported rejection — an invalid parameter, or a failed flash write — changes nothing and leaves the handle OPEN. Any other failure (a timeout, a disconnect) leaves it unknown whether the adapter took the command, so the handle is marked TERMINAL.

Disabling both interfaces at once is believed to be recoverable, but that has not been tested. The reasoning is that CosmicSDK drives the adapter over its USB HID interface, which this command does not touch, so an adapter left with WebUSB and CDC both disabled should still be openable and reconfigurable. That is an inference from the transport CosmicSDK uses, not a bench result — nobody has deliberately disabled both and recovered a unit. Treat it as untested, and do not be the first to find out by accident.

examples/blocking_api/system/usb_config.py reads the configuration and prints it, and deliberately never writes one.

Analog measurements

get_analog_measurements samples all six voltage rails in a single firmware transaction and returns an AnalogMeasurements, every field an unsigned millivolt count. It lives on the device rather than on a protocol interface because no single protocol owns the reading.

It is async-only, and that asymmetry is forced rather than chosen: upstream declares the command on CosmicAsyncSDK alone, so a blocking Device has no route to it. See Blocking and asyncio. A blocking program reads the rails it cares about one at a time.

import asyncio

from pycosmicsdk import AsyncDevice


async def main() -> None:
    async with AsyncDevice.open() as dev:
        rails = await dev.get_analog_measurements()
        print(rails.vtarg_i2c_spi_uart_gpio_mv, rails.vcca_mv)


asyncio.run(main())

Field

Rail

vtarg_i2c_spi_uart_gpio_mv

The multifunction target rail, shared by I2C, SPI, UART and GPIO

vtarg_i3c_hv_mv, vtarg_i3c_lv_mv

The I3C high- and low-voltage target rails

vcca_mv, vcca_i3c_mv, vddio2_mv

Internal supply rails, with no per-protocol reader

Note

The four multifunction voltage getters all read one rail. I2cController.get_bus_voltage, SpiController.get_bus_voltage, Uart.get_bus_voltage and Gpio.get_voltage are four names for the same measurement: each issues this same command and keeps vtarg_i2c_spi_uart_gpio_mv, discarding the other five fields. They return the identical value, because I2C, SPI, UART and GPIO ride one shared VTARG rail — reading two of them is not a cross-check, and a disagreement between them would mean the rail moved between the calls. This has confused people; the same shared rail is why set_voltage on any one of those four moves all four. I3cController.get_bus_voltage is the exception, returning the separate I3C HV/LV pair.

Important

set_voltage succeeding does not mean the rail moved, and lowering it is the case that bites. The adapter’s regulator can source but not sink, so on a lightly loaded multifunction bus a rail commanded downward decays only by leakage: a Supernova asked for 2500 mV read 3291 mV immediately and still read 3291 mV two seconds later — 8 mV of movement, nowhere near the setpoint. Raising the rail is effectively instantaneous.

A second source on the same net makes it worse and is easy to create by accident: two adapters both powered up as I2C controllers both drive VTARG, and then neither reaches its setpoint. The tell is that the other adapter’s reading moves too. Hand the rail to whichever adapter should own it and tell the others it is external — I2cController.use_external_voltage makes an adapter measure instead of drive.

So: get_bus_voltage() immediately after set_voltage() is not a confirmation. If a lower rail matters, load it, allow it to settle, and read it back before trusting it.

get_analog_measurements shares get_usb_config’s failure mode, for the same reason: a reply that does not carry all six measurements raises ProtocolError, and firmware that does not implement the command reports itself exactly that way. The bounds check is this SDK’s own — upstream reads the block by reinterpret_cast with no length check at all — so the alternative to the exception is not a firmware error but garbage.

Warning

A rail the connected model does not fit reads as large, plausible, unstable numbers — not as zero. The field is still populated, with whatever the floating ADC input happens to see. Three successive reads of a Pulsar’s vtarg_i3c_hv_mv gave 512, 11520 and 65535; another session gave 65280, 27135 and 8303, with vddio2_mv at 30032. The values span the whole uint16 range and change between reads that are milliseconds apart.

On a Pulsar that covers vtarg_i3c_hv_mv, vtarg_i3c_lv_mv and vcca_i3c_mv, and vddio2_mv has read implausibly there too. The two fields that were stable and correct on both adapters in every run are vtarg_i2c_spi_uart_gpio_mv and vcca_mv.

The practical hazard is generic validation: a range check, a plausibility assert or a dashboard that treats every field alike passes on a Supernova and reports nonsense on a Pulsar without ever failing. Branch on the model — or on ProtocolInterface.I3C in dev.capabilities.protocols — and ignore the rails the connected adapter does not populate. Do not treat them as 0, and do not average them.

examples/async_api/system/analog_measurements.py reads all six and cross-checks the shared rail against two of the blocking readers.

Examples

examples/blocking_api/system/

list_devices.py — enumerate, filter by model, then open the same physical adapter by path and by serial. get_device_info.py — the context-manager lifecycle, the cached info, the live state, and get_version. get_device_info_simple.py — the explicit open() / close() form with one fetch_device_info(). usb_config.py — reads the saved USB configuration and prints it, and never writes one.

examples/async_api/system/

get_device_info.py and get_device_info_simple.py — the asyncio twins. analog_measurements.py — async-only: all six rails in one transaction, cross-checked against the blocking single-rail readers.

Reference