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 |
|---|---|
|
3.x only |
|
3.x / 4.x |
|
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):
binhopulsar1.2.0: 49 of 52 steps identical. The shim’sgetDeviceInforeportssupported_groupsas[], it has no answer for the two I3C rails a Pulsar lacks, and after the adapter drops off USB it reportsSYS_TIMEOUTwhere 1.2.0 postedUNEXPECTED_DISCONNECTION(#111, open).binhosupernova4.2.0: 67 of 69 identical, plus 1 masked; the 2 that differ are intentional.supernovacontroller2.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 |
|---|---|
|
Returns the accepted-validation dict, then posts |
|
Returns the accepted-validation dict as before, then posts |
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 |
|---|---|
|
3.125 MHz, 12.5 % duty (same 40 ns high time) |
|
100 kHz |
|
400 kHz |
|
1 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 |
|---|---|---|
|
Dual-connector status |
|
|
Accept-controller-role caps |
|
|
ENDXFER direct |
|
|
ENDXFER broadcast (legacy typo preserved) |
Same |
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
I3cConnectorEventNotificationon 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 thewhere=predicate.GETACCCR and ENDXFER: reachable from the
supernovasdkshim (i3cGETACCCR,i3cDirectENDXFER,i3cBroadcastENDXFER) and fromAsyncI3cCcc.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 |
|---|---|
|
Handing out a raw |
|
2.1.0’s interfaces carried the |
|
|
|
Still its own PyPI package and still installable, so direct users are unaffected. |
Legacy enum module trees |
Reproducing |
|
Native |
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 |
Base CosmicSDK has no protocol driver. Native exposes |
1-Wire |
None. One |
Base CosmicSDK. |
CAN-FD |
None. |
Base CosmicSDK. Native has |
I2C target mode, SPI target mode, vendor-bulk transport |
Present only on the legacy clones’ unmerged |
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 raisesDeviceNotFoundErrorand a mid-operation drop raisesDisconnectError, but both carryStatusCode.SDK_ERROR_DISCONNECT.SDK_ERROR_WRONG_REQUESTis the sharper case: it is carried by aValidationError(bad argument), by aCapabilityError(a model or revision limit), by aProtocolError(a malformed request CosmicSDK itself rejected, such as a sub-address longer than four bytes) and by aCosmicInternalError(an SDK bug). The class is the information; the code is not.CapabilityErroris not aFirmwareError. It descends straight fromCosmicError, so a handler written asexcept 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 onstatus_code— nothing was sent, so there is no firmware rejection code to match.DeviceCapabilitiesanswers 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).SpiErroris currently unreachable — upstream declares noFW_SPI_*status code, so SPI failures arrive asFirmwareErrororSystemFirmwareError.UartErrorcovers exactly one code (FW_UART_IN_VCOM_PORT_MODE), reported only byinitialize. CatchFirmwareErrorfor both.CosmicTimeoutErroralso subclassesbuiltins.TimeoutError, so an existingexcept TimeoutError:catches it.
4. Why migrate
Concretely, what the native API gives you that no legacy package could.
An asyncio twin for every protocol.
AsyncDeviceplusAsyncI2cController,AsyncI3cController,AsyncI3cCcc,AsyncI3cTarget,AsyncSpiController,AsyncUart,AsyncGpio. Legacy offered a callback SDK or a blocking wrapper, neverasync/await.examples/async_api/i2c/concurrent_ops.pyfires four reads and a scan with oneasyncio.gatheron a single device. The twins are not quite 1:1, and the exceptions are enumerated rather than approximate:SpiController.write,SpiController.read,SpiController.transportandDevice.discarded_response_countare blocking-only, whileAsyncI3cCcc.transfer,AsyncUart.iter_receive,AsyncDevice.iter_notificationsandAsyncDevice.get_analog_measurementsare 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_voltageon I2C/I3C/SPI/UART,get_voltageon GPIO) is mirrored on both sides — an earlier version of this page said otherwise; see Blocking and asyncio.Typed exceptions instead of status-string dicts. A single
CosmicErrorroot over 16 classes, each carryingstatus_codeandoperation— see the error-handling comparison above. No parsing of["CMD_TIMEOUT", "SYS_NO_ERROR", …].I3C target mode as a first-class surface.
Device.i3c_target()with oneinitialize, a local memory arena (write_memory/read_memory) and typed bus events.list_devices()andmodel=selection. Enumerate what is attached — model, serial, path, manufacturer, product name, hardware revision, firmware version — without opening anything, thenDevice.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.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-typedresultfields.Timeouts.
Device.open(timeout_ms=2000)and a per-call deadline. Legacy had none: a lost response hung forever, andSupernovaController’s own examples included a worker-thread recipe purely to work around it.Capability introspection.
Device.capabilitiesreportsprotocols,i2c_buses,max_i2c_payload_bytes,max_i3c_payload_bytes,max_uart_message_bytes,has_i2c_bus(bus)andsupports(protocol). Legacy hard-coded its limits per package and gave you no way to ask.Typed notifications.
dev.subscribe(UartRxNotification, callback, where=...)— you subscribe by notification class, not by a string name or an enum tag — and get back aSubscriptionusable as a context manager, withAsyncDevice.iter_notifications()as the async stream. Legacy used string-keyed filter/handler pairs where re-registering a name silently did nothing.Context managers and thread safety.
with Device.open() as dev:andasync with AsyncDevice.open() as dev:. A per-instanceRLockserialises lifecycle and FFI calls andweakref.finalizeguarantees teardown; legacy explicitly did not support sharing an instance across threads.USB interface configuration.
get_usb_config()/set_usb_config()overWebusbState/CdcState/CdcMode. No legacy equivalent.Explicit intent over mode switching.
sdr_write,hdr_ddr_writeandlegacy_i2c_writeas three named methods rather than onewrite(mode=...), withI3cTransferFlagsforSTART_WITH_7EandNONSTOP.
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_parametersexplicitly and check the tuple, or power-cycle the adapter before the run so the firstinit_busis a genuine bring-up.On the native API the same condition is loud and specific:
configure()raisesCapabilityErrorbefore anything is sent, anddev.capabilities.i2c_runtime_reconfigurableanswers the question in advance with no USB traffic.BringUpResult.settings_appliedis 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_resistorsstill 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 explicitset_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; neverif 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 |
|---|---|
|
The value is stored; |
|
The merge tests against |
|
The GPIO rail routes through |
|
Honoured and type-checked, on both the shim and the native API. |
Legacy validators chained plain |
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_ADDRESStoinvalid_addressesbut compared a raw result against an enum member, so onlyINVALID_TARGET_ADDRESSever took that branch. Preserved.The ENTDAA seed validator meant to check I3C entries too, but tested each DAA flag with
== True/== Falseagainst an enum member — never equal to a bool — so only I2C entries were collected. Preserved, but the result is now sorted rather than arbitrarysetorder.
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.