# 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 {py:obj}`~pycosmicsdk.Device` and {py:obj}`~pycosmicsdk.AsyncDevice`; where the two differ, the difference is called out and the full account is in [Blocking and asyncio](../asyncio.md). ## Discovery {py:obj}`~pycosmicsdk.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. ```python 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 {py:obj}`~pycosmicsdk.DiscoveredDevice`, and every field on it is ready to use as an `open()` argument or as a filter key: | Field | What it is | |---|---| | {py:obj}`~pycosmicsdk.DiscoveredDevice.model` | The {py:obj}`~pycosmicsdk.DeviceModel`, taken from the USB **product id** — the authoritative model marker at enumeration time, not a guess from a product string. | | {py:obj}`~pycosmicsdk.DiscoveredDevice.serial_number` | Untagged serial, ready to pass as `open(serial=...)`. Empty if the adapter reports none. | | {py:obj}`~pycosmicsdk.DiscoveredDevice.path` | USB-HID device path, ready to pass as `open(path=...)`. Valid only until the adapter is replugged. | | {py:obj}`~pycosmicsdk.DiscoveredDevice.manufacturer`, {py:obj}`~pycosmicsdk.DiscoveredDevice.product_name` | The USB strings, untagged. | | {py:obj}`~pycosmicsdk.DiscoveredDevice.hw_version`, {py:obj}`~pycosmicsdk.DiscoveredDevice.fw_version` | Decoded from the USB release number: `"B"` and `"4.5.0"`. | :::{important} {py:obj}`~pycosmicsdk.DiscoveredDevice` is deliberately **not** {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.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 {py:obj}`Device.open() ` takes three selectors, and they are **mutually exclusive** — passing more than one raises {py:obj}`~pycosmicsdk.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: ```python from pycosmicsdk import AsyncDevice, Device, DeviceModel with Device.open(model=DeviceModel.SUPERNOVA) as dev: print(dev.info.serial_number) ``` ```python async def main() -> None: async with AsyncDevice.open(model=DeviceModel.SUPERNOVA) as dev: print(dev.info.serial_number) ``` {py:obj}`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. {py:obj}`~pycosmicsdk.Device.close` is idempotent, so a `close()` in a `finally` after a `with` block is harmless rather than an error. ### When the open fails {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.Device.state` is a {py:obj}`~pycosmicsdk.DeviceState`, and it is public so you can detect a stale handle without issuing a call that raises. {py:obj}`~pycosmicsdk.Device.is_connected` is the boolean shorthand for `state is DeviceState.OPEN`. | State | How you get there | What still works | |---|---|---| | {py:obj}`~pycosmicsdk.DeviceState.OPEN` | `open()` returned | everything | | {py:obj}`~pycosmicsdk.DeviceState.TERMINAL` | {py:obj}`~pycosmicsdk.Device.reboot`, or a {py:obj}`~pycosmicsdk.Device.set_usb_config` that got no firmware answer | `state`, `info`, `model`, `capabilities`, `close()` | | {py:obj}`~pycosmicsdk.DeviceState.CLOSED` | `close()`, or leaving the `with` block | `state`, `info`, `model`, `capabilities`, `close()` again | {py:obj}`~pycosmicsdk.DeviceState.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 {py:obj}`~pycosmicsdk.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. ```python 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 {py:obj}`~pycosmicsdk.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. ```python 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 {py:obj}`~pycosmicsdk.CapabilityError` before anything is sent: {py:obj}`~pycosmicsdk.DeviceCapabilities.i2c_runtime_reconfigurable`, `supports_i2c_pull_up(pull_up)`, {py:obj}`supports_gpio_interrupt(pin) ` and {py:obj}`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](../reference/types.md). 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. {py:obj}`~pycosmicsdk.DeviceCapabilities.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 {py:obj}`~pycosmicsdk.BulkTransportCapability`, which reports {py:obj}`~pycosmicsdk.BulkTransportCapability.host_transport_open` and {py:obj}`~pycosmicsdk.BulkTransportCapability.device_supported` separately because they are different fixes. Whole protocols are gated the same way: asking a Pulsar for {py:obj}`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 {py:obj}`~pycosmicsdk.Device.info`. The reasoning for each is in [Refusals fail open](../reference/errors.md). :::{important} {py:obj}`~pycosmicsdk.CapabilityError` derives from {py:obj}`~pycosmicsdk.CosmicError`, **not** from {py:obj}`~pycosmicsdk.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](../reference/errors.md). ::: ## Device info: cached versus fresh There are two ways to ask an adapter who it is, and they answer different questions. {py:obj}`~pycosmicsdk.Device.info` : The {py:obj}`~pycosmicsdk.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. {py:obj}`~pycosmicsdk.Device.fetch_device_info` : Issues a fresh `get_device_info` query and returns the result. Requires the handle to be `OPEN`, and raises {py:obj}`~pycosmicsdk.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. {py:obj}`~pycosmicsdk.Device.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"`. | | {py:obj}`~pycosmicsdk.Device.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 {py:obj}`~pycosmicsdk.DiscoveredDevice.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 {py:obj}`~pycosmicsdk.Device.reboot` takes a {py:obj}`~pycosmicsdk.ResetTarget` and replaces what used to be three separate calls. Every target is terminal for the handle. {py:obj}`~pycosmicsdk.ResetTarget.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()`](index.md) — 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. {py:obj}`~pycosmicsdk.ResetTarget.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. {py:obj}`~pycosmicsdk.ResetTarget.ISP` : Reserved by CosmicSDK and not implemented. Raises `NotImplementedError`. ## USB configuration The adapter stores a small USB interface configuration in flash: {py:obj}`~pycosmicsdk.Device.get_usb_config` reads it and {py:obj}`~pycosmicsdk.Device.set_usb_config` writes it, both carrying a {py:obj}`~pycosmicsdk.UsbConfig` with three fields. {py:obj}`~pycosmicsdk.UsbConfig.webusb_enabled` : A {py:obj}`~pycosmicsdk.WebusbState` — `DISABLED`, `ENABLED`, or the write-only sentinel {py:obj}`~pycosmicsdk.WebusbState.NO_CHANGE`, which leaves the field as it is. A read never returns the sentinel. {py:obj}`~pycosmicsdk.UsbConfig.cdc_enabled` : A {py:obj}`~pycosmicsdk.CdcState`, with the same three members and the same write-only meaning for {py:obj}`~pycosmicsdk.CdcState.NO_CHANGE`. {py:obj}`~pycosmicsdk.UsbConfig.cdc_mode` : A {py:obj}`~pycosmicsdk.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: {py:obj}`~pycosmicsdk.CdcMode.TERMINAL` is Supernova-only, and {py:obj}`~pycosmicsdk.CdcMode.RS485` and {py:obj}`~pycosmicsdk.CdcMode.CANFD` are Pulsar-only. Asking for a mode this model cannot run raises {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.Device.get_usb_config` cannot decode raises {py:obj}`~pycosmicsdk.ProtocolError` — *not* a {py:obj}`~pycosmicsdk.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](../reference/errors.md). Because `cdc_mode` always applies, the only safe shape is read-modify-write — never compose a `UsbConfig` from scratch: ```python 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 {py:obj}`~pycosmicsdk.AsyncDevice.get_analog_measurements` samples **all six** voltage rails in a single firmware transaction and returns an {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.Device` has no route to it. See [Blocking and asyncio](../asyncio.md). A blocking program reads the rails it cares about one at a time. ```python 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 | |---|---| | {py:obj}`~pycosmicsdk.AnalogMeasurements.vtarg_i2c_spi_uart_gpio_mv` | The multifunction target rail, shared by I2C, SPI, UART and GPIO | | {py:obj}`~pycosmicsdk.AnalogMeasurements.vtarg_i3c_hv_mv`, {py:obj}`~pycosmicsdk.AnalogMeasurements.vtarg_i3c_lv_mv` | The I3C high- and low-voltage target rails | | {py:obj}`~pycosmicsdk.AnalogMeasurements.vcca_mv`, {py:obj}`~pycosmicsdk.AnalogMeasurements.vcca_i3c_mv`, {py:obj}`~pycosmicsdk.AnalogMeasurements.vddio2_mv` | Internal supply rails, with no per-protocol reader | :::{note} **The four multifunction voltage getters all read one rail.** {py:obj}`I2cController.get_bus_voltage `, {py:obj}`SpiController.get_bus_voltage `, {py:obj}`Uart.get_bus_voltage ` and {py:obj}`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. {py:obj}`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 — {py:obj}`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. ::: {py:obj}`~pycosmicsdk.AsyncDevice.get_analog_measurements` shares {py:obj}`~pycosmicsdk.Device.get_usb_config`'s failure mode, for the same reason: a reply that does not carry all six measurements raises {py:obj}`~pycosmicsdk.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 - {py:obj}`pycosmicsdk.list_devices`, {py:obj}`pycosmicsdk.DiscoveredDevice` - {py:obj}`pycosmicsdk.Device`, {py:obj}`pycosmicsdk.AsyncDevice` - {py:obj}`pycosmicsdk.DeviceState`, {py:obj}`pycosmicsdk.ResetTarget`, {py:obj}`pycosmicsdk.DeviceModel`, {py:obj}`pycosmicsdk.ProtocolInterface` - {py:obj}`pycosmicsdk.DeviceCapabilities`, {py:obj}`pycosmicsdk.BulkTransportCapability`, {py:obj}`pycosmicsdk.DeviceInfo` - {py:obj}`pycosmicsdk.UsbConfig`, {py:obj}`pycosmicsdk.WebusbState`, {py:obj}`pycosmicsdk.CdcState`, {py:obj}`pycosmicsdk.CdcMode` - {py:obj}`pycosmicsdk.AnalogMeasurements` - {py:obj}`pycosmicsdk.DeviceNotFoundError`, {py:obj}`pycosmicsdk.DisconnectError`, {py:obj}`pycosmicsdk.CapabilityError`, {py:obj}`pycosmicsdk.ProtocolError`