# 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](#0-before-you-change-the-import-firmware) - [1. The drop-in path](#1-the-drop-in-path) - [2. What breaks](#2-what-breaks) - [3. Before and after](#3-before-and-after) - [4. Why migrate](#4-why-migrate) - [5. Behaviour that will surprise you](#5-behaviour-that-will-surprise-you) --- ## 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: ```python 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](https://dfu.binho.io) in Chrome or Edge. - On firmware 3.x: the *"Running Firmware v3.x"* steps in [Updating Firmware](https://support.binho.io/user-guide/updating-firmware/) bring the adapter to 4.2.1. Going past 4.2.x needs a one-time bootloader update; ask [techsupport@binho.io](mailto: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](mailto: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` ```python # 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..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. ```python # 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` ```python # 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: ```python # 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](#21-capability-regressions)) plus two signature breaks ([§2.2](#22-signature-breaks)). Working example: `examples/legacy/supernovacontroller/lifecycle.py`. ### 1.3 `binhopulsar` → `pycosmicsdk.legacy.pulsarsdk` ```python # 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](#notification-enum-fields-are-raw-ints--compare-never-truthiness-test) 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. ```python # 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. ```python 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..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 ```python # supernovacontroller devices = SupernovaDevice.getAllConnectedSupernovaDevices() device = SupernovaDevice() info = device.open(devices[0]["path"]) # dict, or raises DeviceOpenError ... device.close() ``` ```python # 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 ```python # 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 ``` ```python # 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: ```python 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](guides/index.md) 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 ```python # supernovacontroller ok, addresses = i2c.scan_bus() ``` ```python # 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 ```python # 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 ``` ```python # 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 ```python # 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] ``` ```python # 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 ```python # supernovacontroller ok, bcr = i3c.ccc_getbcr(0x08) # (True, "0E") — a hex *string*, no prefix ok, pid = i3c.ccc_getpid(0x08) ``` ```python # 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 ```python # 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) ``` ```python # 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 ```python # supernovacontroller spi.init_bus(frequency=10_000_000) # data_width fixed at 8 bits ok, miso = spi.transfer([0x9F, 0, 0, 0], 4) ``` ```python # 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 ```python # 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=...) ``` ```python # 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 ```python # 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"] ... ``` ```python # 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](asyncio.md). 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. ```python 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: ```python 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`. ```python # 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: ```python 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. ```python # 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: ```python # 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 `memcpy`s 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.