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 |
|---|---|
The |
|
Untagged serial, ready to pass as |
|
USB-HID device path, ready to pass as |
|
The USB strings, untagged. |
|
Decoded from the USB release number: |
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.SUPERNOVAThe 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 |
|---|---|---|
|
everything |
|
|
|
|
|
|
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.
infoThe
DeviceInfosnapshot taken atopen(). A property, no I/O, and stable for the lifetime of the handle — including afterclose(). 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_infoIssues a fresh
get_device_infoquery and returns the result. Requires the handle to beOPEN, and raisesDisconnectErrorotherwise.
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 |
|---|---|
|
This Python package. A plain |
The CosmicSDK library — the C/C++ layer running on your PC, compiled into the wheel. Not the adapter’s. |
|
|
The firmware running on the adapter, e.g. |
|
The adapter’s board revision, e.g. |
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.
RUNTIMEA 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 isbring_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 answersFW_INTERFACE_ALREADY_INITIALIZEDto the next handle’sinitialize(). 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.BOOTA 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.RUNTIMEis the reset you want for that.ISPReserved 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_enabledA
WebusbState—DISABLED,ENABLED, or the write-only sentinelNO_CHANGE, which leaves the field as it is. A read never returns the sentinel.cdc_enabledA
CdcState, with the same three members and the same write-only meaning forNO_CHANGE.cdc_modeA
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:TERMINALis Supernova-only, andRS485andCANFDare Pulsar-only. Asking for a mode this model cannot run raisesCapabilityErrorbefore 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 |
|---|---|
The multifunction target rail, shared by I2C, SPI, UART and GPIO |
|
The I3C high- and low-voltage target rails |
|
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 cachedinfo, the livestate, andget_version.get_device_info_simple.py— the explicitopen()/close()form with onefetch_device_info().usb_config.py— reads the saved USB configuration and prints it, and never writes one.examples/async_api/system/get_device_info.pyandget_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
pycosmicsdk.DeviceState,pycosmicsdk.ResetTarget,pycosmicsdk.DeviceModel,pycosmicsdk.ProtocolInterfacepycosmicsdk.DeviceCapabilities,pycosmicsdk.BulkTransportCapability,pycosmicsdk.DeviceInfopycosmicsdk.UsbConfig,pycosmicsdk.WebusbState,pycosmicsdk.CdcState,pycosmicsdk.CdcModepycosmicsdk.DeviceNotFoundError,pycosmicsdk.DisconnectError,pycosmicsdk.CapabilityError,pycosmicsdk.ProtocolError