pycosmicsdk

pycosmicsdk is the Python SDK for the Binho Supernova and Binho Pulsar USB host adapters and the Binho Atira debug probe. One package speaks I2C, I3C, UART, SPI and GPIO on the adapters, drives the Atira’s SWD debug, SPI flash programming and target power, and offers each from either a blocking or an asyncio API.

It is a thin, typed Python layer over the C/C++ CosmicSDK: a compiled nanobind extension ships inside the wheel, so there is no separate native install and no ctypes glue to configure.

It replaces three legacy packages — binhosupernova, binhopulsar and supernovacontroller. If you have a working script against one of those, start at the migration guide instead of here; the legacy compatibility shims under pycosmicsdk.legacy are a migration path, not the recommended API for new code.

Supported hardware

Adapter

I2C

I3C controller

I3C target

UART

SPI

GPIO

Binho Supernova

1 bus (I2cBus.A)

yes

yes

yes

yes

yes

Binho Pulsar

2 buses (I2cBus.A, I2cBus.B)

—

—

yes

yes

yes

Both adapters can also act as an I2C or SPI target rather than a controller, and can move large payloads over a vendor bulk USB endpoint instead of the ordinary 1024-byte HID request. Both are per-model and, for bulk, per-host:

Adapter

I2C target

SPI target

I2C / SPI bulk ceiling

I3C bulk ceiling

Binho Supernova

yes

not rev B

32768 bytes

32767 bytes

Binho Pulsar

yes

yes

16384 bytes

—

Three things in that table are easy to read past. The I2C and SPI ceilings are each that model’s whole bulk staging arena; the I3C one is one byte short of it, because it is a hardware frame limit — the peripheral’s transaction-length field is 15 bits — rather than a buffer size. A rev-B Supernova cannot receive an incoming chip select, so it cannot be a SPI target; firmware does not refuse it, and this SDK does. Only that one revision is refused — an unrecognised model or a revision string this SDK cannot read is attempted, so the refusal is always a specific claim rather than a default. And bulk additionally needs the adapter’s vendor USB interface claimed on this machine, so the same adapter answers differently on two PCs — which is why it is negotiated per connection rather than looked up per model. See The bulk transfer plane and Target mode.

Both adapters expose I2C, SPI, UART and the GPIO pins on a shared multifunction port, which is also a shared VTARG rail — setting the target voltage through any one of those four interfaces sets it for all four. Only the Supernova has I3C hardware; asking a Pulsar for i3c raises CapabilityError rather than failing on the wire.

The Supernova’s Qwiic port and multifunction-port I2C pins are internally shorted into the single logical I2cBus.A. The Pulsar’s two I2C ports are electrically separate buses that share only VTARG and ground.

Query it at runtime instead of hard-coding it:

from pycosmicsdk import Device, ProtocolInterface

with Device.open() as dev:
    print(dev.model)                                     # DeviceModel.SUPERNOVA
    print(sorted(dev.capabilities.protocols))
    print(ProtocolInterface.I3C in dev.capabilities.protocols)

Binho Atira debug probe

The Atira is driven by DebugProbe (async twin AsyncDebugProbe), not by Device, and can be open in the same process as a Supernova or Pulsar. It needs fw-atira v1.0 or newer.

Function

Available

Target power (3.3 V rail, voltage / current readings)

yes

SWD debug on Arm Cortex-M (attach, halt, step, registers, memory, breakpoints)

yes

SPI controller and SPI NOR flash (detect, read, erase, program, verify)

yes

USB-to-UART bridge (port name, saved line settings)

yes

RTT, SWO, UPDI, JTAG

not in this release

See the Atira debug probe guide.

Install

pip install pycosmicsdk

That is the whole setup — the native library is statically linked into the compiled extension the wheel ships. Linux additionally needs a udev rule before an adapter or probe is reachable without root.

Getting started has all of it: the install, the per-platform prerequisites, opening a first adapter on both APIs, and what the errors mean.

Quickstart: an I2C read

Plug in a Supernova or Pulsar, wire an I2C target to bus A, and run:

from pycosmicsdk import Device, I2cBus, I2cPullUp

with Device.open() as dev:
    i2c = dev.i2c(bus=I2cBus.A)
    i2c.set_voltage(voltage_mv=3300)
    i2c.bring_up(frequency_hz=400_000, pull_up=I2cPullUp.OHM_330)
    print(i2c.read(0x50, 4, subaddress=b"\x00\x00").hex(" "))

That is the whole program: open the first adapter found (pass serial= to pick one), power the bus, bring it up, read four bytes from register 0x0000 of the target at 0x50. A target that does not answer raises I2cError with the firmware’s reason in the message. The fuller version below adds device info, a bus scan and the bring_up result:

from pycosmicsdk import Device, I2cBus, I2cPullUp

with Device.open() as dev:
    print(f"{dev.model.name} {dev.info.serial_number}")

    i2c = dev.i2c(bus=I2cBus.A)
    i2c.set_voltage(voltage_mv=3300)

    # Re-runnable: brings the bus up, or reconfigures it if an earlier run
    # left it up. On return the bus is usable either way.
    result = i2c.bring_up(frequency_hz=400_000, pull_up=I2cPullUp.OHM_330)
    if not result.settings_applied:
        print(f"bus kept its earlier settings: {result.refusal}")

    # scan() raises I2cError on an empty bus; a mis-wired target fails here.
    print(f"targets on bus A: {[hex(a) for a in i2c.scan().addresses_7bit]}")

    # Read 16 bytes from register 0x0100 of the target at 0x50.
    data = i2c.read(address=0x50, length=16, subaddress=b"\x01\x00")
    print(data.hex(" "))

Six things in there are worth knowing up front:

Device.open() is a context manager.

Leaving the with block closes the native handle. The explicit close form works too — see examples/blocking_api/i2c/blocking_manual_close.py.

bring_up() is the re-runnable bring-up.

This is the first thing that will bite you, and bring_up is the answer to it. initialize is not idempotent: an already-initialised bus reports FW_INTERFACE_ALREADY_INITIALIZED, and the bus stays initialised across reconnects — so the second run of any script hits it unless the adapter was reset in between. bring_up tries initialize, falls back to configure when the bus is already up, and hands back a BringUpResult. I2C, I3C, UART and SPI all have one, and so do the I2C and SPI target interfaces — six in all, on both the blocking class and its asyncio twin.

BringUpResult.settings_applied is the field to branch on.

On return the bus is up and usable — anything that would leave it down raises, so there is no half-success to check for. What settings_applied answers is whether it is running at your settings. It is False in exactly one case: the bus was already up and the adapter refused to reconfigure it, which leaves the bus working at settings you did not choose and cannot read back. A script that just needs a working bus can ignore the return value; a measurement that depends on the frequency must not. raise result.refusal turns the report back into an exception.

A capability refusal is not a FirmwareError.

The second thing that will bite you, and the reason bring_up exists in library code rather than as a snippet to copy. Some operations are refused before anything reaches the device, because the connected model or hardware revision cannot do them — configure and set_pull_up on a Supernova whose revision is anything but C, an unsupported I2cPullUp value, an interrupt on a GPIO pin this revision cannot arm. Those raise CapabilityError, which derives from CosmicError and not from FirmwareError, so except FirmwareError does not catch it. Two rules follow if you ever write such a handler yourself. Catch it in its own clause, and do not filter that clause on status_code: nothing was sent, so the code carried is SDK_ERROR_WRONG_REQUEST rather than any firmware rejection code. Better still, ask first — DeviceCapabilities answers every one of these in advance, with no USB traffic: dev.capabilities.i2c_runtime_reconfigurable, dev.capabilities.supports_i2c_pull_up(pull_up), dev.capabilities.supports_gpio_interrupt(pin), dev.capabilities.spi_target_capable and dev.capabilities.supports_spi_bulk(). See Capabilities you can ask about in advance.

initialize() returns the interface.

So dev.i2c(bus=I2cBus.A).initialize(...) chains when you know the bus is down. bring_up() returns the report instead, which is the trade: chaining or knowing what took effect.

Payloads are bytes, in and out.

Reads return bytes; writes take bytes. No lists, no bytearray ceremony.

Failures are exceptions.

Nothing returns a status code. Everything the SDK raises derives from CosmicError — see Errors.

The same script in asyncio form is async with AsyncDevice.open() as dev: with an await on each operation; see Blocking and asyncio.

Examples

Download the runnable scripts as pycosmicsdk-examples.zip — blocking and asyncio versions for every protocol, the Atira debug probe and the legacy shims. Unzip, pip install pycosmicsdk, and run one with python pycosmicsdk-examples/blocking_api/system/list_devices.py. Each script’s docstring lists the wiring it needs; the protocol guides describe the layout.

Where to go next

  • The device lifecycle — discovery, the three open() selectors, the state machine, capabilities, rebooting, and the USB configuration. The handle every protocol guide sits inside.

  • Protocol guides — one page per protocol, each pointing at the runnable scripts under examples/, plus two cross-cutting pages: The bulk transfer plane and Target mode.

  • Atira debug probe — target power, SWD debug, SPI flash programming and the UART bridge on a Binho Atira.

  • Blocking and asyncio — how the two APIs relate, and the four places where they are genuinely not mirror images.

  • API reference — generated from the docstrings in src/pycosmicsdk/.

  • Migrating from a legacy SDK — for existing binhosupernova, binhopulsar or supernovacontroller code.