Migrating to pycosmicsdk

You have a working script against binhosupernova, supernovacontroller or binhopulsar. This guide answers, in order: will it still run, what do I change, and why bother.

Every signature below was read off the source; every snippet comes from examples/. The method inventories in §1 (87 / 28 / six interface names) and the two shim behaviours in §5 were last re-checked against the working tree and against real adapters — the bench run is docs_dev/hardware_validation/legacy-shims-on-silicon-2026-09-03.md. Where a claim rests on firmware source rather than a bench reading, it says so.


0. Before you change the import: firmware

pycosmicsdk needs firmware 4.x on the adapter. Firmware 4.0 changed how commands travel over USB, and this package speaks only the 4.x framing, so a firmware 3.x adapter receives nothing it sends. Changing the import is not enough if your adapter runs 3.x — and it does if your script uses SupernovaController, whose published 2.1.0 pins binhosupernova 3.2.0, the last SDK for firmware 3.x.

Legacy package

Firmware it drives

SupernovaController 2.1.0

3.x only

binhosupernova 3.2.0 / 4.2.0

3.x / 4.x

binhopulsar 0.3.0 / 1.2.0

3.x / 4.x

Check first. Nothing is sent to the adapter; the version comes from its USB descriptor:

import pycosmicsdk
for d in pycosmicsdk.list_devices():
    print(d.model, d.serial_number, d.fw_version)

"4.5.0" is ready. "3.x.x" is firmware 3.x (that scheme carries no minor or patch). "bootloader" means the adapter is waiting in its bootloader, not running firmware.

On firmware 3.x the USB descriptor carries only the first 31 of the serial’s 32 characters, so list_devices() prints 31. Device.open(serial=...) takes the 31- or the 32-character form.

Update forward. Use the latest release; this package is tested on 4.5.0, and features added there (bulk transfers, releasing an I2C/SPI target role) answer FW_UNSUPPORTED_COMMAND on firmware that predates them.

  • On firmware 4.3.0 or later: Mission Control’s firmware update, or dfu.binho.io in Chrome or Edge.

  • On firmware 3.x: the “Running Firmware v3.x” steps in Updating Firmware bring the adapter to 4.2.1. Going past 4.2.x needs a one-time bootloader update; ask techsupport@binho.io.

If you skip it, Device.open() raises UnsupportedFirmwareError naming the firmware it found, or DeviceInBootloaderError, instead of timing out. Both arrive in 0.1.1; earlier builds raised CosmicTimeoutError after the command timeout.

Programs qualified on firmware 3.2.0 that cannot be requalified: contact techsupport@binho.io before reflashing.

What “compatible” means today: each legacy package was run side by side with its shim on one adapter, 3 runs per side (docs_dev/hardware_validation/legacy-differential-pulsarsdk-2026-09-23.md, docs_dev/hardware_validation/legacy-differential-supernova-2026-09-23.md):

  • binhopulsar 1.2.0: 49 of 52 steps identical. The shim’s getDeviceInfo reports supported_groups as [], it has no answer for the two I3C rails a Pulsar lacks, and after the adapter drops off USB it reports SYS_TIMEOUT where 1.2.0 posted UNEXPECTED_DISCONNECTION (#111, open).

  • binhosupernova 4.2.0: 67 of 69 identical, plus 1 masked; the 2 that differ are intentional.

  • supernovacontroller 2.1.0 (on firmware 3.2.0) against the shim (on 4.5.0): the run found 13 shim defects, all fixed in 0.1.1 (#113 to #116).


1. The drop-in path

Three compatibility shims live under src/pycosmicsdk/legacy/. Each reproduces its legacy package’s method names, argument order and return shapes — dicts with status strings for the two driver-level packages, (bool, payload) tuples for SupernovaController. For most scripts the change is the import line plus the enum import paths.

All three emit a DeprecationWarning on import. Silence it with python -W ignore::DeprecationWarning or warnings.filterwarnings if it breaks a test harness that treats warnings as errors.

1.1 binhosupernova → pycosmicsdk.legacy.supernovasdk

# before
from binhosupernova.supernova import Supernova
from binhosupernova import getConnectedSupernovaDevicesList

# after
from pycosmicsdk.legacy.supernovasdk import Supernova, getConnectedSupernovaDevicesList

from pycosmicsdk.legacy.supernovasdk.supernova import Supernova also works, so the module path mirrors the legacy one if you prefer.

Covered: 89 public methods — the full I2C, I3C controller, I3C target, UART, SPI and GPIO surfaces, the full CCC set, device info, analog measurements, voltage control (including setI3cVoltage, useExternalI3cVoltage, useExternalI2cSpiUartGpioVoltage), reboot, onEvent with its INVALID_CALLBACK_SIGNATURE check, and the callback/dict transaction model with your own uint16 ids echoed back.

Not covered:

Legacy call

What happens now

getI3cConnectorsStatus(id)

Returns the accepted-validation dict, then posts FEATURE_NOT_SUPPORTED_BY_HARDWARE to your onEvent callback with state and connector_type set to None on both ports. Nothing is sent: CosmicSDK reserves CMD_SYS_GET_I3C_CONNECTOR_STATUS and offers no entry point. Plug and unplug still arrive as connector notifications.

enterIspMode(id)

Returns the accepted-validation dict as before, then posts FW_FEATURE_NOT_SUPPORTED_BY_HARDWARE to your onEvent callback — a deferred failure, so if response["opcode"] != 0 will not catch it. binhosupernova 4.2.0 did serialise and send a real ENTER_ISP_MODE command; CosmicSDK reserves CMD_SYS_ENTER_ISP_MODE for a future release, so there is nothing to call underneath. Whether the firmware ever honoured the legacy command is not something this guide can confirm. Native Device.reboot(ResetTarget.ISP) raises NotImplementedError for the same reason.

Enum imports must be rewritten. The legacy per-protocol module tree (binhosupernova.commands.<proto>.definitions) is not reproduced; the enums and constants are re-exported flat from the shim package. That covers every enum 4.2.0 exported – command and result codes included – and its argument limits and name tables; only the wire-layout constants (lengths, byte indexes, masks) are left out.

# before
from binhosupernova.commands.gpio.definitions import GpioPinNumber
from binhosupernova.commands.i2c.definitions import I2cPullUpResistorsValue

# after
from pycosmicsdk.legacy.supernovasdk import GpioPinNumber, I2cPullUpResistorsValue

Working example: examples/legacy/supernovasdk/i2c_api.py.

1.2 supernovacontroller → pycosmicsdk.legacy.supernovacontroller

# before
from supernovacontroller.sequential import SupernovaDevice
from supernovacontroller.errors import BackendError

# after
from pycosmicsdk.legacy.supernovacontroller.sequential import SupernovaDevice
from pycosmicsdk.legacy.supernovacontroller.errors import BackendError

All six create_interface() names are registered: "i2c", "i3c.controller", "i3c.target", "uart", "spi.controller", "gpio". Anything else still raises UnknownInterfaceError; an unmounted device still raises DeviceNotMountedError; instances are still cached per name.

One deliberate divergence, and it can only bite a caller who bypasses SupernovaDevice.getAllConnectedSupernovaDevices: the two I3C names are model-gated, so create_interface("i3c.controller") or create_interface("i3c.target") on a Pulsar raises UnknownInterfaceError with a message naming the adapter. 2.1.0 had no such gate, but it also drove a PID-filtered driver that could not be handed a Pulsar unless the caller passed that Pulsar’s own HID path — so it never defined a behaviour here rather than choosing to allow one. Handing the interface out anyway is the worst option available: the I3C command group is not model-gated in firmware either, so the calls report success, and merely building the controller interface sends an I3C controller-init. On a Pulsar that path was observed returning five plausible-looking targets whose bytes were ASCII fragments of the adapter’s own serial number.

All seven legacy exception classes are reproduced, including BackendError.original_exception (with __cause__ preserved). The (True, data) / (False, str) / (False, [str]) tuple shapes are reproduced by a purpose-built translation layer, including the multi-layer error lists. PYTHON_LOG_PATH / PYTHON_LOG_LEVEL logging and the empty supernovacontroller.utils.mctp placeholder are both there.

Enums that legacy sourced from BinhoSupernova.commands.definitions are re-exported from the shim package:

# before
from BinhoSupernova.commands.definitions import GpioPinNumber, UartControllerBaudRate

# after
from pycosmicsdk.legacy.supernovacontroller import GpioPinNumber, UartControllerBaudRate

The I3C ones are there too — TransferMode, both rate enums, I3cTargetMemoryLayout_t, the I3cTarget*_t BCR fields, I3cBcrRegister_t, DdrOk / IgnoreTE0TE1Errors / MatchStartStop / AlwaysNack and the seed-table flags (TargetType, TargetInterruptRequest, …) — with 3.2.0’s names and values, and the three 2.1.0 promoted stay on the interface as well (i3c.I3cPushPullTransferRate, i3c.TransferMode, …). Every name and member 2.1.0’s examples, tests and README import resolves.

I3C rates firmware 4.x does not have. 2.1.0’s rate names all work. Four push-pull ones (PUSH_PULL_5/6_25/7_5/10_MHZ) map to the 4.x rate with the same frequency and the same 40 ns SCL high time firmware 3.x used, so the waveform is unchanged. The rest do not exist in 4.x and run at the fastest rate that is not faster than the one asked for, with a LegacyRateSubstitutionWarning:

2.1.0 rate

runs at

PUSH_PULL_3_75_MHZ (2.1.0’s default)

3.125 MHz, 12.5 % duty (same 40 ns high time)

OPEN_DRAIN_250_KHZ

100 kHz

OPEN_DRAIN_500_KHZ

400 kHz

OPEN_DRAIN_1_25_MHZ

1 MHz

OPEN_DRAIN_2_5_MHZ / OPEN_DRAIN_3_125_MHZ

2 MHz

A script that never calls set_parameters gets 2.1.0’s default, 3.75 MHz / 100 kHz, as above. i3c.TransferMode.I3C_HDR_TS and I3C_HDR_BT — marked not supported in 3.2.0 — return (False, "... no transfer was sent.").

Seed tables and targets(). init_bus(targets=...) and ccc_entdaa(...) take the camelCase entries 2.1.0 documented (staticAddress, dynamicAddress, maxIbiPayloadLength, i3cFeatures as an int built from the 3.2.0 flags) as well as the snake_case ones targets() returns. targets() and find_target_device_by_pid() return 2.1.0’s five keys — bcr and dcr as ints, pid as "0x07"-style strings — and ccc_entdaa() returns (True, None), as 2.1.0 did.

Not covered: five calls that now return a failure tuple (§2.1) plus two signature breaks (§2.2). Working example: examples/legacy/supernovacontroller/lifecycle.py.

1.3 binhopulsar → pycosmicsdk.legacy.pulsarsdk

# before
from binhopulsar.pulsar import Pulsar
from binhopulsar import getConnectedPulsarDevicesList

# after
from pycosmicsdk.legacy.pulsarsdk import Pulsar, getConnectedPulsarDevicesList

All 28 public methods are present, busId: I2cBus second-positional on every I2C call, and getAnalogMeasurements is implemented. There is no PulsarController to migrate — none ever existed; Pulsar had no blocking/sequential legacy API at all.

Not covered: enterIspMode, with the same deferred-failure treatment described in §1.1.

RS-485, 1-Wire and CAN-FD are absent, matching binhopulsar on main, which exposed no methods for them either — only Group enum members.


2. What breaks

2.1 Capability regressions

The four I3C calls below are the core of it: a SupernovaController script that gets a real answer from the device today gets a failure tuple after migrating. All four are gated upstream — there is no CosmicSDK entry point to call at any layer, so this cannot be fixed inside pycosmicsdk. One more follows the table (measure_analog_signal), and one more is in §1.1 (enterIspMode, which affects all three shims).

Legacy call

Today

After migrating

i3c.get_i3c_connector_status()

Dual-connector status

(False, ["I3C connector status is not offered by the packaged CosmicSDK, which reports connector plug and unplug only as notifications."])

i3c.ccc_getacccr(addr)

Accept-controller-role caps

(False, "The GETACCCR CCC is not offered by the packaged CosmicSDK, so no CCC was sent.")

i3c.ccc_unicast_endxfer(addr)

ENDXFER direct

(False, "The ENDXFER CCC is not offered by the packaged CosmicSDK, so no CCC was sent.")

i3c.ccc_broadcast_endxfed()

ENDXFER broadcast (legacy typo preserved)

Same ENDXFER message

The wording is deliberate: none of these can be mistaken for a device that rejected your request. Nothing was sent.

Workarounds:

  • Connector status: subscribe to I3cConnectorEventNotification on the native API. Plug and unplug arrive as events rather than a poll — but read the note on raw-int notification fields in §5 before you write the where= predicate.

  • GETACCCR and ENDXFER: reachable from the supernovasdk shim (i3cGETACCCR, i3cDirectENDXFER, i3cBroadcastENDXFER) and from AsyncI3cCcc.transfer(...) on the native async API, both of which drive the async facade and its generic CCC transfer. The blocking facade has no generic CCC transfer, which is why the controller-level shim cannot reach them.

A fifth call is a regression of a different kind. device.measure_analog_signal() returns (False, ["Analog signal measurement is not offered by the packaged CosmicSDK, so no measurement was attempted."]) because upstream declares getAnalogMeasurements only on the async SDK, not the blocking one this shim drives. The capability itself is available three other ways: await AsyncDevice.get_analog_measurements() (returns AnalogMeasurements with six millivolt rails), getAnalogMeasurements on the supernovasdk and Pulsar shims, and — for individual rails — get_bus_voltage() on the I2C interface and the (hv, lv) pair from I3cController.get_bus_voltage().

2.2 Signature breaks

Two calls that are syntactically valid legacy code stop working.

ccc_unicast_setgrpa gained a second parameter.

# 2.1.0 documented one argument — but SETGRPA cannot be sent without a
# group address, so this call could never have assigned a meaningful one.
ok, err = i3c.ccc_unicast_setgrpa(0x08)
# -> (False, 'SETGRPA needs the group address to assign; pass it as group_address.')

ok, _ = i3c.ccc_unicast_setgrpa(0x08, group_address=0x0C)   # works: (True, None), as 2.1.0 CCCs

The shim refuses rather than inventing a group address.

target_init / set_configuration accept features and useconds_to_wait_for_ibi for signature compatibility and ignore them. Upstream i3cTargetInit / i3cTargetSetParameters take neither a configuration byte nor an IBI wait time.

ok, warn = i3c_target.target_init(
    memory_layout=MEMORY_LAYOUT,
    useconds_to_wait_for_ibi=100,
    max_read_length=64,
    max_write_length=64,
    features=TARGET_FEATURES,
)
# -> (True, ['The I3C target configuration byte and the IBI wait time are not
#            offered by the packaged CosmicSDK, so the target was left with its
#            firmware defaults.'])

Note the shape: (True, [message]) — a success tuple carrying a warning list, which legacy never produced. The target came up; two arguments were dropped. Passing the defaults (features=None, useconds_to_wait_for_ibi=0) gives a clean (True, None). If your code does if not ok: raise, it keeps working; if it does if result: raise, it will now see a truthy warning list on a successful call.

2.3 Removed on purpose — our decision, not upstream’s

Removed

Why

SupernovaDevice.driver

Handing out a raw BinhoSupernova.Supernova was never a contract and there is no such object in the new architecture. A _Driver stand-in keeps is_open checks working; dev.driver.i3cGETBCR(...) breaks.

create_interface(...) objects’ controller, driver and GPIO’s configured_pins

2.1.0’s interfaces carried the TransferController, the BinhoSupernova driver and a pin dict as plain attributes. No public example, test or documented call reads them, and none of those objects exists here.

pulsar._Pulsar__driver.i3c*

PulsarSDK/API.md §2.8 already labelled it unsupported. Pulsar has no I3C hardware, so it only ever let you call methods that could not succeed.

transfer_controller re-export

Still its own PyPI package and still installable, so direct users are unaffected. AsyncDevice supersedes it with timeouts and no leak.

Legacy enum module trees

Reproducing BinhoSupernova.commands.<proto>.definitions as real packages would cement a layout nobody wants. Flat re-export instead — see §1.1.

spiControllerTransfer(transferLength, payload) decoupling

Native transfer(data) is fixed to len(data). To read past what you write, pad data and slice the reply. A legacy call whose transferLength disagreed with len(payload) now behaves differently.

2.4 Deferred protocols — gated upstream, not chosen

These are not losses: a legacy user could not reach any of them either.

Protocol / feature

Legacy exposure

Gate

RS-485

None. Only a Group.RS485 = 9 command-group enumerator.

Base CosmicSDK has no protocol driver. Native exposes CdcMode.RS485 for USB-CDC routing, which is more than legacy had.

1-Wire

None. One SYS_SET_1WIRE_VOLTAGE command code, no facade method.

Base CosmicSDK.

CAN-FD

None.

Base CosmicSDK. Native has CdcMode.CANFD for USB-CDC routing.

I2C target mode, SPI target mode, vendor-bulk transport

Present only on the legacy clones’ unmerged feat/i2c-spi-target-mode branch — absent from released binhosupernova 4.2.0 and binhopulsar 1.2.0.

An unmerged upstream CosmicSDK branch plus a libusb packaging decision.

Target-initiated IBIs

Unimplemented in firmware.

Firmware.

Do not confuse the I2C/SPI target-mode row with I3C target mode, which is fully supported natively (Device.i3c_target()) and is one of the reasons to migrate rather than one of the costs.

2.5 10-bit I2C transfers are shim-only

Native I2cController.write / read are 7-bit. Upstream declares the target as a single uint8_t on both the blocking and async transfer signatures, so the device has nowhere to put the extra three bits. include_10bit on scan() is discovery, not transfer, and works fine.

If you need 10-bit transfers, stay on the shim: both driver-level shims pack the address host-side and reproduce the legacy validator messages verbatim (examples/legacy/supernovasdk/i2c_10bit_addressing.py).


3. Before and after

Legacy first, native second, for the tasks people actually do on day one. Every native snippet is drawn from examples/.

Discovery and open

# supernovacontroller
devices = SupernovaDevice.getAllConnectedSupernovaDevices()
device = SupernovaDevice()
info = device.open(devices[0]["path"])       # dict, or raises DeviceOpenError
...
device.close()
# pycosmicsdk — examples/blocking_api/i3c/discovery.py
from pycosmicsdk import Device, DeviceModel, list_devices

for d in list_devices(model=DeviceModel.SUPERNOVA):
    print(d.model.name, d.serial_number, d.hw_version, d.fw_version, d.path)

with Device.open(model=DeviceModel.SUPERNOVA) as dev:
    print(dev.info.serial_number, dev.info.fw_version)

Device.open() is keyword-only: serial=, path=, model=, timeout_ms=2000. serial, path and model are mutually exclusive; list_devices(model=None) lists every supported model.

I2C write and read

# binhosupernova — callback model
sn.i2cControllerInit(get_id(), 400_000, I2cPullUpResistorsValue.I2C_PULLUP_4_7kOhm)
collector.wait()
sn.i2cControllerWrite(get_id(), 0x50, [0x00, 0x00], [0xAA, 0xBB, 0xCC])
collector.wait()
sn.i2cControllerRead(get_id(), 0x50, 3, [0x00, 0x00])
collector.wait()          # payload arrives in the onEvent dict
# pycosmicsdk — examples/blocking_api/i2c/blocking.py
i2c = dev.i2c(bus=I2cBus.A)
i2c.set_voltage(voltage_mv=3300)
i2c.initialize(frequency_hz=200_000, pull_up=I2cPullUp.OHM_330)

i2c.write(address=0x50, data=b"\xaa" * 128, subaddress=b"\x01\x00", non_stop=True)
data = i2c.read(address=0x50, length=128, subaddress=b"\x01\x00", non_stop=True)

bytes in, bytes out, and the result is the return value rather than a callback dict. initialize raises FirmwareError with status_code == StatusCode.FW_INTERFACE_ALREADY_INITIALIZED on a re-run — configure(...) is the repeatable twin. helpers.i2c_bring_up shows that fallback.

You do not have to write that fallback. bring_up(...) is the re-runnable composite over the two, on I2cController, I3cController, Uart, SpiController and their asyncio twins. It takes the same arguments as initialize and returns a BringUpResult rather than the interface:

result = dev.i2c(bus=I2cBus.A).bring_up(
    frequency_hz=200_000, pull_up=I2cPullUp.OHM_330
)
if not result.settings_applied:
    raise result.refusal

On return the bus is up and usable; settings_applied says whether it is up at your settings, which it is not when the bus was already initialised and the adapter refused to reconfigure it. That has no legacy analogue at all — the legacy packages simply re-sent the init command — so it is worth reading the bring-up section of the guides once. Two interfaces have no bring_up, for two different reasons: Gpio has no initialize/configure pair at all, and I3cTarget.initialize is already idempotent — upstream’s i3cTargetInit re-initialises unconditionally and never reports FW_INTERFACE_ALREADY_INITIALIZED — so target mode just calls initialize() on every run.

One thing the legacy shims have no equivalent of: configure and set_pull_up can be refused before the wire, with CapabilityError, on a Supernova whose hardware revision cannot reconfigure a live I2C interface — and so can all three of initialize, configure and set_pull_up for a pull-up value the connected model’s firmware has no table row for. The legacy surfaces do not guard, so the same call there reaches the device and comes back as a firmware code. Two rules on the modern API: CapabilityError is not a FirmwareError, so it needs its own except clause; and it carries SDK_ERROR_WRONG_REQUEST rather than a firmware code, so that clause must not filter on status_code. Ask dev.capabilities.i2c_runtime_reconfigurable and dev.capabilities.supports_i2c_pull_up(pull_up) to avoid the question entirely. helpers.i2c_bring_up has the shape that works.

I2C scan

# supernovacontroller
ok, addresses = i2c.scan_bus()
# pycosmicsdk — examples/blocking_api/i2c/blocking.py
scan = i2c.scan(include_10bit=True)
for addr in scan.addresses_7bit:
    print(f"0x{addr:02x}")
for addr in scan.addresses_10bit:
    print(f"0x{addr:04x}")

I2cScanResult carries addresses_7bit and addresses_10bit as separate collections instead of one flat list.

UART query

# supernovacontroller
uart.init_bus(baudrate=UartControllerBaudRate.UART_BAUD_115200)
uart.send(b"Hello, world!")
ok, reply = uart.wait_for_notification(timeout=2.0)   # race: RX armed after TX
# pycosmicsdk — examples/blocking_api/uart/query.py
uart = dev.uart()
uart.set_voltage(voltage_mv=3300)
uart.initialize(baudrate=UartBaudRate.BAUD_115200)

echo = uart.query(b"Hello, world!", timeout=2.0)
uart.configure(baudrate=UartBaudRate.BAUD_9600)
echo = uart.query(b"Hello, world!", timeout=2.0)

query() enables reception, discards stale RX, transmits, and waits — the subscription is live before the bytes go out, so there is no race. receive(), deinit() and (on AsyncUart only) iter_receive() round out the surface. receive() is safe to call concurrently with competing-consumer semantics; query() is not, because it drains the queue before sending — do not mix the two.

Two gotchas: Uart.set_voltage takes voltage_mv keyword-only, unlike I2C/SPI/GPIO where it is positional. And initialize(send_only_mode=True) skips arming reception; a later receive/query still works (RX arms lazily) but may have missed everything that arrived first, and logs a warning under pycosmicsdk.uart when it does.

I3C bring-up and discovery

# supernovacontroller
i3c = device.create_interface("i3c.controller")
i3c.set_parameters(
    i3c.I3cPushPullTransferRate.PUSH_PULL_12_5_MHZ,
    i3c.I3cOpenDrainTransferRate.OPEN_DRAIN_4_17_MHZ,
)
i3c.init_bus(3300)
ok, targets = i3c.targets()      # list[dict]
# pycosmicsdk — examples/blocking_api/i3c/discovery.py
i3c = dev.i3c()
i3c.set_voltage(voltage_mv=3300)
i3c.initialize(
    push_pull_rate=I3cPushPullRate.PUSH_PULL_5_MHZ_DC_50,
    open_drain_rate=I3cOpenDrainRate.OPEN_DRAIN_400_KHZ,
    i2c_open_drain_rate=I2cOpenDrainRate.FAST_MODE,
)
targets = i3c.init_bus()         # list[I3cTargetInfo]

init_bus() and get_device_table() both return list[I3cTargetInfo] with dynamic_address, static_address, pid (bytes), bcr, dcr. An empty list is a valid outcome, not an error. I3cController.set_voltage takes only a voltage — no persist, no port selector: 800–1199 mV drives the low-voltage connector, 1200–3300 mV the high-voltage one.

A CCC read

# supernovacontroller
ok, bcr = i3c.ccc_getbcr(0x08)   # (True, "0E") — a hex *string*, no prefix
ok, pid = i3c.ccc_getpid(0x08)
# pycosmicsdk — examples/blocking_api/i3c/ccc_queries.py
pid = i3c.ccc.getpid(address)    # bytes
bcr = i3c.ccc.getbcr(address)    # int, not "0E"
dcr = i3c.ccc.getdcr(address)    # int

getbcr/getdcr/getmwl return int; getpid/getstatus/getcaps/getmxds/getxtime return bytes; only getmrl returns a typed record (I3cGetMrlResult, with max_read_length and an optional ibi_payload_size). The shim keeps the legacy hex strings; the native API does not.

i3c.ccc is a property reaching the CCC surface. Every CCC takes an optional flags: I3cTransferFlags keyword. Broadcast and direct forms are separate, explicitly named methods (enec_broadcast / enec_direct, setmrl_broadcast / setmrl_direct, …) rather than one call with a mode argument — but note two that carry no suffix and are broadcast anyway: rstdaa and entdaa. The blocking I3cCcc has no generic transfer(); AsyncI3cCcc.transfer(...) does. setnewda takes keyword-only old= / new=.

GPIO

# binhosupernova
sn.gpioConfigurePin(get_id(), GpioPinNumber.GPIO_3, GpioFunctionality.DIGITAL_OUTPUT)
sn.gpioDigitalWrite(get_id(), GpioPinNumber.GPIO_3, GpioLogicLevel.HIGH)
sn.gpioSetInterrupt(get_id(), GpioPinNumber.GPIO_3, GpioTriggerType.TRIGGER_RISING_EDGE)
# pycosmicsdk — examples/blocking_api/gpio/digital_io.py
gpio = dev.gpio()
gpio.set_voltage(voltage_mv=3300)
gpio.configure_pin(GpioPinNumber.PIN_3, mode=GpioFunctionality.DIGITAL_OUTPUT)
gpio.write(GpioPinNumber.PIN_3, GpioLogicLevel.HIGH)
level = gpio.read(GpioPinNumber.PIN_1)

Pin members are PIN_1 … PIN_6. configure_pin(initial=...) is honoured — binhopulsar 1.2.0 accepted initialOutputLogicLevel and silently dropped it. Re-running configure_pin to flip direction is allowed and drops any interrupt armed on the pin. Writing to a pin configured as an input raises ValidationError before any facade call.

SPI

# supernovacontroller
spi.init_bus(frequency=10_000_000)                 # data_width fixed at 8 bits
ok, miso = spi.transfer([0x9F, 0, 0, 0], 4)
# pycosmicsdk — examples/blocking_api/spi/transfer.py
spi = dev.spi()
spi.set_voltage(voltage_mv=3300)
spi.initialize(
    bit_order=SpiBitOrder.MSB,
    mode=SpiMode.MODE_0,
    data_width=SpiDataWidth.BITS_8,
    chip_select=SpiChipSelect.CS_0,
    chip_select_polarity=SpiChipSelectPolarity.ACTIVE_LOW,
    frequency_hz=10_000_000,
)
received = spi.transfer(bytes([0x9F, 0x00, 0x00, 0x00]))

SpiDataWidth.BITS_16 is restored — the legacy driver had it, SupernovaController hid it.

I3C target mode

# supernovacontroller
i3c_target = target_dev.create_interface("i3c.target")
i3c_target.set_pid([0xFF, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE])
i3c_target.set_bcr(...)
i3c_target.set_dcr(...)
i3c_target.set_static_address(0x50)
i3c_target.target_init(memory_layout=..., useconds_to_wait_for_ibi=100,
                       max_read_length=64, max_write_length=64, features=...)
# pycosmicsdk — examples/blocking_api/i3c/target_mode_setup.py
dev.i3c().set_voltage(voltage_mv=3300)     # same rail, via the controller
target = dev.i3c_target()
target.initialize(
    memory_layout=I3cTargetMemoryLayout.MEMORY_1_BYTE_X_1024_REGS,
    pid=bytes.fromhex("0F0000000001"),
    bcr=0x00,
    dcr=0xC5,
    static_address=0x50,
    max_write_length=1024,
    max_read_length=1024,
)
target.write_memory(0, bytes(range(32)))
read_back = target.read_memory(0, 32)
event = target.wait_for_event(timeout=5.0)   # typed I3cTargetBusEvent

One initialize call replaces the incremental setter chain, and it is unconditional — no ALREADY_INITIALIZED fallback needed.

Error handling

# supernovacontroller — tuple archaeology
ok, result = i2c.write(0x50, [0x00], [0xAA])
if not ok:
    # result is a str, or a list[str] like
    # ["CMD_TIMEOUT", "SYS_NO_ERROR", "I3C_DRIVER_NOT_INITIALIZED"]
    ...
# pycosmicsdk — the pattern in examples/blocking_api/i2c/error_handling.py
from pycosmicsdk import (
    CapabilityError, CosmicError, FirmwareError, StatusCode, ValidationError,
)

try:
    i2c.write(address=0x50, data=b"\xaa")
except ValidationError as exc:          # bad argument, rejected before any SDK call
    print(exc.status_code, exc.operation)
except CapabilityError as exc:          # this model/revision cannot; nothing sent
    print(exc)                          # never status-filtered -- see below
except FirmwareError as exc:            # I2cError, I3cError, SpiError, ...
    if exc.status_code is StatusCode.FW_INTERFACE_ALREADY_INITIALIZED:
        ...
except CosmicError:                     # the single root
    ...

Every exception carries status_code (a StatusCode, or a plain int for an unmapped firmware code) and an optional operation label. Four things to know when you write the except clauses:

  • Discriminate on class, not status_code. A failed open raises DeviceNotFoundError and a mid-operation drop raises DisconnectError, but both carry StatusCode.SDK_ERROR_DISCONNECT. SDK_ERROR_WRONG_REQUEST is the sharper case: it is carried by a ValidationError (bad argument), by a CapabilityError (a model or revision limit), by a ProtocolError (a malformed request CosmicSDK itself rejected, such as a sub-address longer than four bytes) and by a CosmicInternalError (an SDK bug). The class is the information; the code is not.

  • CapabilityError is not a FirmwareError. It descends straight from CosmicError, so a handler written as except FirmwareError — the natural translation of a legacy “the device said no” branch — will not catch it. It is raised before any USB traffic: for a protocol the model does not have, a pull-up value its firmware has no row for, a live-I2C reconfigure its hardware revision cannot do, or a GPIO interrupt on a pin that revision cannot arm. Give it its own clause, and do not filter that clause on status_code — nothing was sent, so there is no firmware rejection code to match. DeviceCapabilities answers every one of those in advance: supports(protocol), has_i2c_bus(bus), i2c_runtime_reconfigurable, supports_i2c_pull_up(pull_up), supports_gpio_interrupt(pin).

  • SpiError is currently unreachable — upstream declares no FW_SPI_* status code, so SPI failures arrive as FirmwareError or SystemFirmwareError. UartError covers exactly one code (FW_UART_IN_VCOM_PORT_MODE), reported only by initialize. Catch FirmwareError for both.

  • CosmicTimeoutError also subclasses builtins.TimeoutError, so an existing except TimeoutError: catches it.


4. Why migrate

Concretely, what the native API gives you that no legacy package could.

  1. An asyncio twin for every protocol. AsyncDevice plus AsyncI2cController, AsyncI3cController, AsyncI3cCcc, AsyncI3cTarget, AsyncSpiController, AsyncUart, AsyncGpio. Legacy offered a callback SDK or a blocking wrapper, never async/await. examples/async_api/i2c/concurrent_ops.py fires four reads and a scan with one asyncio.gather on a single device. The twins are not quite 1:1, and the exceptions are enumerated rather than approximate: SpiController.write, SpiController.read, SpiController.transport and Device.discarded_response_count are blocking-only, while AsyncI3cCcc.transfer, AsyncUart.iter_receive, AsyncDevice.iter_notifications and AsyncDevice.get_analog_measurements are async-only. Every one of those eight is a decision with a stated reason — three of them because upstream declares the call on one of its two pins only — and nothing else differs. Every voltage read-back (get_bus_voltage on I2C/I3C/SPI/UART, get_voltage on GPIO) is mirrored on both sides — an earlier version of this page said otherwise; see Blocking and asyncio.

  2. Typed exceptions instead of status-string dicts. A single CosmicError root over 16 classes, each carrying status_code and operation — see the error-handling comparison above. No parsing of ["CMD_TIMEOUT", "SYS_NO_ERROR", …].

  3. I3C target mode as a first-class surface. Device.i3c_target() with one initialize, a local memory arena (write_memory / read_memory) and typed bus events.

  4. list_devices() and model= selection. Enumerate what is attached — model, serial, path, manufacturer, product name, hardware revision, firmware version — without opening anything, then Device.open(model=DeviceModel.SUPERNOVA) instead of a hard-coded serial. One SDK covers both adapters; legacy needed two PID-pinned packages, one of which bundled a patched duplicate of the other’s driver.

  5. Full type annotations. Every public argument and return is annotated, and results are typed records — DeviceInfo, DiscoveredDevice, I2cScanResult, I3cTargetInfo, I3cTargetConfiguration, I3cGetMrlResult, I3cTargetBusEvent, AnalogMeasurements, UsbConfig — instead of dicts with stringly-typed result fields.

  6. Timeouts. Device.open(timeout_ms=2000) and a per-call deadline. Legacy had none: a lost response hung forever, and SupernovaController’s own examples included a worker-thread recipe purely to work around it.

  7. Capability introspection. Device.capabilities reports protocols, i2c_buses, max_i2c_payload_bytes, max_i3c_payload_bytes, max_uart_message_bytes, has_i2c_bus(bus) and supports(protocol). Legacy hard-coded its limits per package and gave you no way to ask.

  8. Typed notifications. dev.subscribe(UartRxNotification, callback, where=...) — you subscribe by notification class, not by a string name or an enum tag — and get back a Subscription usable as a context manager, with AsyncDevice.iter_notifications() as the async stream. Legacy used string-keyed filter/handler pairs where re-registering a name silently did nothing.

  9. Context managers and thread safety. with Device.open() as dev: and async with AsyncDevice.open() as dev:. A per-instance RLock serialises lifecycle and FFI calls and weakref.finalize guarantees teardown; legacy explicitly did not support sharing an instance across threads.

  10. USB interface configuration. get_usb_config() / set_usb_config() over WebusbState / CdcState / CdcMode. No legacy equivalent.

  11. Explicit intent over mode switching. sdr_write, hdr_ddr_write and legacy_i2c_write as three named methods rather than one write(mode=...), with I3cTransferFlags for START_WITH_7E and NONSTOP.


5. Behaviour that will surprise you

Even where the capability exists, these differ. The four that follow were measured against real adapters rather than reasoned about, and the run that found them is docs_dev/hardware_validation/legacy-shims-on-silicon-2026-09-03.md. Its headline is the reassuring part and is worth stating first: every read through every shim matched the modern API byte for byte — the sensor data a migrating script gets is the same data. What differed was never a value; it was how a limit of the connected adapter is reported, and three of the four are specific to supernovacontroller.

supernovacontroller: init_bus() can succeed while your settings do not take

This one is silent, and it is the one to read. On a Supernova whose hardware revision cannot reconfigure a live I2C peripheral — anything but HW-C — the second and every later run of an unmodified script takes this path, because the peripheral stays initialised across reconnects.

i2c = device.create_interface("i2c")
ok, voltage = i2c.init_bus(3300)        # (True, 3300) -- the bus IS up and usable
ok, freq = i2c.get_parameters()         # ...but this is the PREVIOUS run's frequency

What happens underneath: init_bus sends i2cControllerInit, gets FW_INTERFACE_ALREADY_INITIALIZED, falls through to the reconfigure command, and the adapter answers FW_FEATURE_NOT_SUPPORTED_BY_HARDWARE. The shim treats that as what it is — a refused reconfigure, not a failed bring-up — so the bus stays up, every transfer keeps working, and init_bus reports success. The frequency and pull-up you asked for were simply not applied, and SupernovaI2CBlockingInterface.get_parameters reports the value that is actually in force rather than the one you passed.

The call that does tell you is SupernovaI2CBlockingInterface.set_parameters, which returns (False, 'Set parameters failed') on the same refusal — matching 2.1.0, whose set_parameters submitted i2cSetParameters alone and reported exactly that when it failed, after which the interface kept working. So:

  • If your script only needs a working bus, nothing changes and you can ignore this.

  • If your script depends on the clock rate or the pull-up — a timing measurement, a marginal bus — call set_parameters explicitly and check the tuple, or power-cycle the adapter before the run so the first init_bus is a genuine bring-up.

  • On the native API the same condition is loud and specific: configure() raises CapabilityError before anything is sent, and dev.capabilities.i2c_runtime_reconfigurable answers the question in advance with no USB traffic. BringUpResult.settings_applied is the modern shape of exactly this report.

An earlier build conflated the two and failed completely here — every I2C call through the shim returned (False, 'HARDWARE_FEATURE_NOT_SUPPORTED') with no recovery short of a power cycle, on a bus that was up and answering the whole time. If you are reading this after meeting that, the fix is in.

A failure leg still carries a plausible value — check result, never key presence

This is legacy behaviour faithfully reproduced rather than a new hazard, but it is the one most likely to survive a migration unnoticed, because the shape that hides it (if "logic_level" in response:) reads as defensive.

Both driver-level shims fill the same keys on the failure leg as on the success leg, with a fabricated value: Supernova.gpioDigitalRead and Pulsar.gpioDigitalRead report logic_level as "LOW" whether or not the read succeeded, a failed voltage read reports voltage_mV as 0, a failed transfer reports payload_length 0 and an empty payload, and Pulsar.getAnalogMeasurements reports every rail as 0 when the query failed. The legacy decoders had no success branch and read their fields off a zero-initialised overlay, so this is what a legacy script has always seen.

The consequence is that 0 and "LOW" are indistinguishable from real readings. An all-zero analog dict looks exactly like a genuinely unpowered rail. So branch on response["result"] (or, on the controller shim, on the tuple’s first element) and read the payload only after it says the call succeeded. On the native API the question does not arise: a failed read raises, and there is no partly-populated record to misread.

A related one on supernovasdk: every I2C response carries "i2c_bus": "I2C_BUS_A" as a constant on both legs, because the Supernova has only one logical bus. pulsarsdk reports the real bus. Do not carry a supernovasdk habit of reading that field across to a two-bus adapter.

supernovacontroller: a bare open() opens a Supernova

SupernovaDevice.open() with no usb_address opens the first Supernova the host enumerates, as 2.1.0 did: an attached Pulsar is never picked, and with no Supernova attached the call raises DeviceOpenError. The native Device.open() has no such filter; Device.open(model=DeviceModel.SUPERNOVA) is its equivalent.

The equivalent trap on the SDK-level shims is fixed: Supernova.open() now filters HID enumeration on the Supernova’s own USB ids, so it can no longer open a Pulsar while printing that it opened a Supernova.

supernovacontroller: the I2C pull-up default is model-dependent

2.1.0’s default was 10 kΩ, and 10 kΩ has no row in the Pulsar’s firmware table — so on a Pulsar the faithful default turned every call through the I2C interface into (False, 'I2C_DATA_FORMAT_ERROR'), starting with init_bus. Loud, but pointing at the transfer layer rather than at the pull-up.

The shim no longer reproduces that. It resolves the default from the connected adapter’s own pull-up table: 10 kΩ on a Supernova, as 2.1.0 had it, and 330 Ω on a Pulsar — a value in both models’ tables and confirmed accepted on both. This is a deliberate divergence with no faithful alternative, because 2.1.0 could not reach a Pulsar at all: it drove a PID-filtered enumerator.

Two things follow for a migrating script:

  • The default only. set_pull_up_resistors still accepts every resistance 2.1.0’s dict spelled, on every adapter, and still lets firmware answer for one this model has no row for. So an explicit set_pull_up_resistors(10000) on a Pulsar still fails, with (False, ['I2C_DATA_FORMAT_ERROR']), and that is correct.

  • A model this SDK does not recognise keeps 2.1.0’s 10 kΩ, because an empty capability table must not be read as “10 kΩ is unsupported here”. Same fail-open principle as the native guards.

The per-model split is worth carrying in your head: I2cPullUp.KOHM_3_3, I2cPullUp.KOHM_4_7 and I2cPullUp.KOHM_10_0 are Supernova-only; I2cPullUp.KOHM_4_0 is Pulsar-only; the other nine values are on both. On the native API a value the model has no row for is refused host-side with a CapabilityError naming both the model and the values it does accept, and dev.capabilities.i2c_pull_ups lists them.

Device.open() with no arguments takes the first enumerated adapter

On a two-adapter bench that may not be the one you want — and if it is a Pulsar, an I3C script fails in a way that looks like a firmware or SDK bug, because the Pulsar has no I3C port at all. Every I3C example in this repo therefore selects by model:

with Device.open(model=DeviceModel.SUPERNOVA) as dev:

A bare open() takes the first Binho device in the host’s own HID enumeration and does not error on multiple; model= narrows that to the first of that model, and also does not error on multiple. list_devices() groups by model first, so its order is not the order a bare open() follows — do not read list_devices()[0] as “what open() will pick”. No ordering here is stable across replugs. Select by model= when the scenario needs a model, by serial= when it needs a specific unit.

DeviceInfo strings are tagged; DiscoveredDevice strings are not

The firmware’s USB string descriptors carry a three-character tag, so an open adapter reports serial_number="SN-634F…", hw_version="HW-B", manufacturer="MN-Binho LLC". Enumeration sees the same fields untagged.

Only the untagged serial is accepted by Device.open(serial=...) — passing dev.info.serial_number raises DeviceNotFoundError. Use list_devices()[i].serial_number. This is why discovery has its own record type rather than reusing DeviceInfo.

GPIO pin_number carries the enum value, not the printed label

GpioInterruptNotification.pin_number is the zero-based GpioPinNumber value. A rising edge on GPIO_3 (PIN_3) arrives as pin_number == 2.

# examples/blocking_api/gpio/interrupt_subscribe.py
with dev.subscribe(
    GpioInterruptNotification,
    on_irq,
    where=lambda n: n.pin_number == GpioPinNumber.PIN_3,   # correct
):

Comparing against the enum member works because GpioPinNumber binds as arithmetic (IntEnum) — see the next section for the general rule. Do not “fix” this to n.pin_number == 3; that silently matches PIN_4.

One more in the same area: triggered_edge on the notification is always a single edge, never BOTH_EDGES, even when the pin was armed with BOTH_EDGES.

Notification enum fields are raw ints — compare, never truthiness-test

GpioInterruptNotification and I3cConnectorEventNotification carry their enum-valued fields as plain int (the raw firmware values). Both sets of enums are therefore bound arithmetic (enum.IntEnum), so a direct comparison works and is safe in a where= predicate:

where=lambda n: n.event == I3cConnectorEvent.PLUGGED    # correct
where=lambda n: n.pin_number == GpioPinNumber.PIN_3     # correct

The rule the SDK follows: any enum whose value can reach Python as an int is bound arithmetic. Two consequences:

  • Zero-valued members are falsy — GpioPinNumber.PIN_1, GpioFunctionality.DIGITAL_INPUT, GpioLogicLevel.LOW, I3cConnectorPort.LV, I3cConnectorEvent.PLUGGED, I3cConnectorType.UNIDENTIFIED. Compare explicitly; never if n.event:.

  • The fields stay int, so a firmware value this release does not name still arrives rather than raising. Compare against the members you care about instead of exhaustively converting.

This last point is why you should not convert inside a predicate (GpioPinNumber(n.pin_number) == ...): predicates run on the dispatcher thread, where exceptions are logged and swallowed, so an unenumerated value would silently drop the event.

It matters most for the connector-event stream, which is the substitute for the retired get_i3c_connector_status() (§2.1).

I3cTargetBusEvent and I3cTargetBusEventNotification are different types

I3cTarget.wait_for_event() returns the dataclass I3cTargetBusEvent, whose kind is an I3cTargetBusEventKind (or a bare int for a kind this SDK does not know — compare with is, and treat anything else as unrecognised). dev.subscribe(...) delivers the notification class I3cTargetBusEventNotification, whose kind is always a raw int. Both are public; they are not interchangeable.

This is the one place where the raw-int rule above does not rescue you: unlike the GPIO and connector enums, I3cTargetBusEventKind is not bound arithmetic — the SDK’s own re-wrap on the wait_for_event() path is what makes it a typed enum there. On the notification path a direct comparison is therefore always False; convert first.

# wait_for_event() path — typed, compare with `is`
event.kind is I3cTargetBusEventKind.WRITE_TRANSFER

# subscribe() path — raw int, must convert
I3cTargetBusEventKind(n.kind) is I3cTargetBusEventKind.WRITE_TRANSFER

Guard that conversion against an unrecognised firmware value, and remember a where= predicate that raises is logged and swallowed on the dispatcher thread — so do the conversion in the callback, not the filter.

I3C target memory addresses are register indices

I3cTarget.write_memory(address, data) and read_memory(address, length) take a register index, not a byte offset. It is scaled by the layout’s register size, so the usable range is 0–1023 for the 1-byte layout, 0–511 for 2-byte and 0–255 for 4-byte.

The system_message handed to onEvent is now a dict

Both driver-level shims pass a dict as the callback’s second argument. Legacy passed a live SystemMessage instance there — the one place it did not call toDictionary(). So:

# before
if system_message.opcode == SystemOpcode.UNEXPECTED_DISCONNECTION: ...
# after — AttributeError otherwise
if system_message["opcode"] == SystemOpcode.UNEXPECTED_DISCONNECTION.value: ...

Every returned system message was already a dict, so only the callback’s second argument is affected. SystemOpcode is exported from both shims.

A call that used to hang now raises

There were no timeouts anywhere in legacy. Device.open(timeout_ms=2000) and the controller shim’s COMMAND_TIMEOUT_MS = 5000 change that: a silent device raises CosmicTimeoutError natively, or BackendError through the shim. Strictly better, but it is a behaviour change — and any worker-thread scaffolding you built to bound a blocking call is now dead code.

open()["hw_version"] and get_hardware_version() disagree on purpose

On the controller shim, open() returns the stripped revision ("C") where legacy gave "HW-C rev 01"; get_hardware_version() returns the prefixed form ("HW-C"), because callers branch on startswith("HW-"). The rev NN suffix is gone from both.

Legacy bugs that were deliberately not reproduced

Where a legacy defect had no defensible contract, the shim behaves correctly instead. A script that depended on the broken behaviour will now behave differently — and better.

Legacy defect

Now

SupernovaController I2C set_parameters returned the frequency it was handed but never stored it, so get_parameters reported the 1 MHz default forever.

The value is stored; get_parameters reports what was last accepted.

SupernovaController UART merged parameters with self.x = x or self.x, so any falsy argument was discarded — hardware_handshake=False could never turn an enabled handshake back off.

The merge tests against None, so False disables the handshake.

SupernovaController GPIO picked its voltage-write method from the hardware-version string, and on a revision matching neither HW-B nor HW-C, set_pins_voltage silently became a no-op that still reported success.

The GPIO rail routes through gpioSetVoltage; there is no branch to get wrong and the voltage is always applied.

binhopulsar gpioConfigurePin accepted initialOutputLogicLevel and did not forward it.

Honoured and type-checked, on both the shim and the native API.

Legacy validators chained plain if statements over a shared result object, so a call with several bad arguments reported the last failing check’s message.

Validation returns on the first bad argument. Single-bad-argument calls — the common case — report identical messages.

Two legacy bugs were kept, because fixing them would have changed the response dict or the validated set a working script already depends on:

  • The CCC response union meant to route REPEATED_TARGET_ADDRESS to invalid_addresses but compared a raw result against an enum member, so only INVALID_TARGET_ADDRESS ever took that branch. Preserved.

  • The ENTDAA seed validator meant to check I3C entries too, but tested each DAA flag with == True / == False against an enum member — never equal to a bool — so only I2C entries were collected. Preserved, but the result is now sorted rather than arbitrary set order.

SupernovaController’s HDR-DDR path ignored the register-address list even though its validator still checked it against the four-byte maximum. The shim reproduces both halves of that. On the native API the question cannot arise: hdr_ddr_write / hdr_ddr_read take a DDR command code and no subaddress, so there is nothing to silently drop.

10-bit I2C scan byte order

If you compared 10-bit scan results between the native facade and legacy during early development, they disagreed: the facade decoded the address bytes big-endian, the legacy decoder little-endian. The firmware settled it in legacy’s favour — i2c_mgr.c builds the numeric address and memcpys the uint16_t array onto the wire with no serialisation, so the bytes arrive in the MCU’s native little-endian order. The facade was corrected in edc4d0e; there is no divergence now. 7-bit addresses are one byte each and were never affected.

Notification re-registration is still a no-op on the shim

on_notification(name, filter_func, handler_func) still silently does nothing if name is already registered — the legacy quirk is reproduced. On the native API, dev.subscribe(...) returns an independent Subscription every time.