Metadata-Version: 2.4
Name: pycosmicsdk
Version: 1.5.1
Summary: Python SDK built on top of the C/C++ CosmicSDK.
Author: Binho LLC
License-Expression: LicenseRef-Binho-Proprietary AND LGPL-2.1-or-later AND BSD-3-Clause
License-File: LICENSE
License-File: LICENSE.libusb
License-File: NOTICE
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Operating System :: MacOS
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: System :: Hardware :: Hardware Drivers
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown

# pycosmicsdk

Python SDK for the **Binho Supernova** and **Binho Pulsar** USB host adapters, wrapping the
C/C++ [CosmicSDK](https://github.com/binhollc/CosmicSDK) in a typed (PEP 561) `nanobind`
extension. The native library is statically linked into the extension — no separate install,
no `ctypes` glue, no `lib_path` to configure.

Every protocol has a blocking class and an `asyncio` twin (`Device` / `AsyncDevice`,
`I2cController` / `AsyncI2cController`, and so on).

| Protocol | Surface | Supernova | Pulsar |
| --- | --- | --- | --- |
| I2C | controller (7-bit write / read, scan) and target mode | bus A | buses A and B |
| UART | send / query / receive, streamed or subscribed | yes | yes |
| I3C | controller, all 38 CCCs, and target mode | yes | — |
| SPI | controller and target mode | controller yes; target mode not on rev B | yes |
| GPIO | digital I/O and interrupts | yes | yes |

Large payloads can travel over the adapter's vendor **bulk** USB endpoint instead of the
ordinary 1024-byte HID request — up to 32768 bytes on a Supernova and 16384 on a Pulsar
(32767 for I3C, which is a hardware frame limit rather than a buffer size). It reaches five
of the six protocol/role combinations; the I3C *target* has none by design, serving its bus
from a private 1024-byte buffer instead. Bulk is negotiated per connection and needs the
vendor USB interface claimed on *this* machine, so the same adapter answers differently on
two PCs. It is blocking-only. See
[`docs/api/guides/bulk.md`](docs/api/guides/bulk.md) and
[`docs/api/guides/target-mode.md`](docs/api/guides/target-mode.md).

Also on `Device` / `AsyncDevice`: `list_devices()`, device info and capabilities, voltage and
external-rail control, USB configuration, `reboot()`, and a notification subscription system;
`AsyncDevice` adds `get_analog_measurements()` and `iter_notifications()`.

## Install

> **Not on PyPI yet.** `pycosmicsdk` 0.1.0 is on **TestPyPI** only — 15 wheels (CPython
> 3.10–3.14 × Linux `x86_64` / macOS `arm64` / Windows `AMD64`), uploaded 2026-09-10 by
> `.github/workflows/publish.yml`. The PyPI upload is the same workflow with `target: pypi`,
> gated on the `0.1.0` milestone (#70). Until then `pip install pycosmicsdk` does **not**
> work; install from TestPyPI (`docs/api/getting-started.md`) or from the source checkout
> described below.

Requirements:

- Python `>= 3.10`. Development is pinned to `3.12.13` via `.python-version` and fetched
  automatically by `uv` (no system Python dev headers needed).
- A C++17 toolchain and CMake `>= 3.18` — the extension is compiled from source by
  `scikit-build-core` + CMake. On Linux and macOS a compiler on `PATH` (gcc / clang) is what
  is needed. **On Windows the compiler does not have to be on `PATH`**: CMake selects the
  Visual Studio generator and locates the toolchain through the VS installation, so what you
  need is Visual Studio or Build Tools with the C++ workload — verified by running `uv sync`
  with every VS directory stripped from `PATH`, which still succeeds.
  The documented path also needs **no** Developer Command Prompt. That changes if you export
  `CMAKE_GENERATOR=Ninja` or invoke `cmake` by hand: Ninja inherits the shell's environment
  rather than MSBuild's, so `rc.exe` is missing and the compiler check fails on
  `RC Pass 1`. Run `vcvars64.bat` first in that case.
- [`uv`](https://docs.astral.sh/uv/) for environment and dependency management.
- Access to the **CosmicSDK** submodule, which is a separate private repository.

```bash
export SKBUILD_CMAKE_ARGS=-DCMAKE_POLICY_VERSION_MINIMUM=3.5   # required, see below

git clone https://github.com/binhollc/pycosmicsdk.git
cd pycosmicsdk
git config submodule.submodules/CosmicSDK.url https://github.com/binhollc/CosmicSDK.git
git submodule update --init --recursive

uv sync
```

Over **SSH** those four lines collapse to
`git clone --recurse-submodules git@github.com:binhollc/pycosmicsdk.git`, but only with a key
that can read *both* `binhollc/pycosmicsdk` and `binhollc/CosmicSDK`. The two recipes are
mutually exclusive: `git config submodule.<name>.url` needs a repository that already exists,
so it cannot be combined with `--recurse-submodules`.

Worth recognising, because the failure is indirect: without submodule access the superproject
clone **succeeds** and leaves `submodules/CosmicSDK` empty, and the error surfaces later as a
CMake `add_subdirectory` failure during `uv sync` that mentions nothing about credentials.

`SKBUILD_CMAKE_ARGS=-DCMAKE_POLICY_VERSION_MINIMUM=3.5` is **not optional** and is not set in
`pyproject.toml`: CosmicSDK fetches hidapi via `FetchContent` at tag `hidapi-0.14.0`, whose
`cmake_minimum_required(VERSION 3.1.3)` sits below CMake 4's policy floor, so without the
override a fresh configure fails. Export it before any command that builds the extension,
`uv sync` included. On Windows PowerShell:
`$env:SKBUILD_CMAKE_ARGS = "-DCMAKE_POLICY_VERSION_MINIMUM=3.5"`.

Platform notes:

- **Linux** — install the udev rules once so the adapter is reachable without root:
  `./submodules/CosmicSDK/install_udev_rules.sh`. Building from source also needs the
  `libudev` and `libusb` development packages (`libudev-dev` and `libusb-1.0-0-dev` on
  Debian/Ubuntu; names vary elsewhere); the `manylinux` wheel bundles both libraries, so a
  wheel install needs neither.
- **macOS / Windows** — the adapters are USB HID devices and need no driver install.
- **Windows, running a built wheel** — no Visual C++ Redistributable is required.
  `native/CMakeLists.txt` links the MSVC runtime statically, so the compiled `.pyd`
  imports only `python3XX.dll` and `KERNEL32.dll`. This matters because CPython's own
  Windows installer ships `vcruntime140.dll` but not `msvcp140.dll`, so a dynamically
  linked extension would fail to import on a machine without Visual Studio. Building
  from source is unaffected and still needs the C++ toolchain listed above.

## Quickstart

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

```python
from pycosmicsdk import Device, I2cBus, I2cPullUp, list_devices

for found in list_devices():                 # USB enumeration only; opens nothing
    print(f"{found.model.name} serial={found.serial_number} fw={found.fw_version}")

with Device.open() as dev:                   # or open(model=…), open(serial=…), open(path=…)
    print(f"{dev.model.name} {dev.info.serial_number}")

    i2c = dev.i2c(bus=I2cBus.A)
    i2c.set_voltage(voltage_mv=3300)         # VTARG before the bus, always

    # 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}")

    print(f"targets on bus A: {[hex(a) for a in i2c.scan().addresses_7bit]}")

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

Adapted from
[`examples/blocking_api/system/list_devices.py`](examples/blocking_api/system/list_devices.py)
and [`examples/blocking_api/i2c/blocking.py`](examples/blocking_api/i2c/blocking.py); run
either for the full version.

Five things worth knowing up front:

- `Device.open()` is a context manager; leaving the `with` block closes the native handle.
  An explicit `close()` also works.
- Payloads are `bytes` in and `bytes` out. No lists, no `bytearray` ceremony.
- Failures are exceptions, never status codes, and all derive from `CosmicError`.
- **`bring_up()` is the re-runnable bring-up, and it is what the second run of any script
  needs.** `initialize()` is not idempotent — an already-initialised bus reports
  `FW_INTERFACE_ALREADY_INITIALIZED`, and the peripheral stays initialised across
  reconnects, so a script that calls `initialize()` directly fails the second time it is
  run unless the adapter was reset in between. `bring_up()` tries `initialize()`, falls
  back to `configure()`, and returns a `BringUpResult` whose `settings_applied` says
  whether the settings you asked for actually took. I2C, I3C, UART and SPI all have one,
  as do the I2C and SPI *target* interfaces — six in all, on both the blocking class and
  its asyncio twin. (`I3cTarget` has none, and needs none: its `initialize` is already
  idempotent.) `initialize()` and `configure()` remain
  public and supported for when you know which state the interface is in.
- **A `CapabilityError` is not a `FirmwareError`.** Some calls are refused before anything
  reaches the device, because the connected model or hardware revision cannot do them.
  `CapabilityError` derives straight from `CosmicError`, so `except FirmwareError` will not
  catch it, and it carries `SDK_ERROR_WRONG_REQUEST` rather than a firmware code, so a
  handler for it must not filter on `status_code`. Better: ask `dev.capabilities` first.

The `asyncio` twin is the same script with `async with AsyncDevice.open() as dev:` and an
`await` on each operation — see [`examples/async_api/`](examples/async_api/).

## Where to go next

| Where | What |
| --- | --- |
| [`docs/api/`](docs/api/) | User documentation (Sphinx source). Build with `./docs/api/build.sh`. |
| [`docs/api/migration.md`](docs/api/migration.md) | Migrating from the legacy packages. |
| [`examples/`](examples/) | Runnable scripts, one directory per protocol, blocking and async. |

## Migrating from the legacy packages

Three compatibility shims under `pycosmicsdk.legacy` reproduce the method names, argument
order and return shapes of the packages they replace:

| Legacy package | Shim |
| --- | --- |
| `binhosupernova` | `pycosmicsdk.legacy.supernovasdk` |
| `SupernovaController` | `pycosmicsdk.legacy.supernovacontroller` |
| `binhopulsar` | `pycosmicsdk.legacy.pulsarsdk` |

The adapter must run firmware 4.x first: `SupernovaController` users are on 3.x, which this
package cannot reach. [Migration §0](docs/api/migration.md#0-before-you-change-the-import-firmware)
says how to check and update.

All three emit a `DeprecationWarning` on import and are a migration path, not the recommended
API for new code. [`docs/api/migration.md`](docs/api/migration.md) has the per-method
detail, what breaks, and before/after snippets.

## Logging

The SDK uses the standard `logging` module, with loggers under the `pycosmicsdk` hierarchy:

| Logger                   | Source                                                    |
| ------------------------ | --------------------------------------------------------- |
| `pycosmicsdk.backend`    | exceptions caught at the C++→Python notification bridge   |
| `pycosmicsdk.dispatcher` | exceptions raised by user-supplied subscription callbacks |
| `pycosmicsdk.aio`        | the asyncio path (`AsyncEngine`, `AsyncDevice`)           |
| `pycosmicsdk.i3c`        | the I3C interfaces                                        |
| `pycosmicsdk.spi`        | the SPI interfaces — one INFO line per connection recording which transport large payloads take |
| `pycosmicsdk.uart`       | the UART interfaces                                       |

The three legacy shims log under `pycosmicsdk.legacy.*`, so the same parent covers them too.

Set the level on the `pycosmicsdk` parent to cover all of them:

```python
import logging

logging.basicConfig(level=logging.DEBUG)
logging.getLogger("pycosmicsdk").setLevel(logging.DEBUG)
```

The SDK installs no handlers of its own; consumers attach their own, following the standard
library guidance.

---

# Contributing

## Cloning

`.gitmodules` records the CosmicSDK submodule over SSH
(`git@github.com:binhollc/CosmicSDK.git`). If you work over HTTPS, override the URL locally
before initialising.

```bash
# HTTPS override, if needed
git config submodule.submodules/CosmicSDK.url https://github.com/binhollc/CosmicSDK.git

git submodule update --init --recursive          # after a clone without --recurse-submodules
git submodule update --remote submodules/CosmicSDK   # pull upstream submodule changes later
```

## Development setup

```bash
uv sync                      # create .venv and install runtime + dev deps
uv run pre-commit install    # register git hooks
```

`uv sync` builds the C++ extension only on first install or when build inputs change;
imports of `pycosmicsdk` from Python never recompile, and neither does `uv run`. After
editing anything under `native/`, force a rebuild:

```bash
uv sync --reinstall-package pycosmicsdk
```

Without it, tests and examples silently keep using the previously-compiled extension.
`--reinstall-package` is targeted — it rebuilds our extension without touching the rest of
the venv, and CMake/ninja are incremental, so it is fast (≈ 1–3 s) when no `.cpp` changed.
Pure-Python edits need no rebuild.

If something breaks, start over:

```bash
rm -rf .venv
uv venv
uv sync --all-groups
uv run pre-commit install
```

## Running examples

Scripts under [`examples/`](examples/) exercise the SDK against a real Binho USB host adapter.
They are not part of the test suite and require a device to be plugged in.
[`examples/criteria.md`](examples/criteria.md) documents the conventions they follow.

```bash
uv run python examples/blocking_api/system/get_device_info.py
uv run python examples/run_all_examples.py      # discover and run every example
```

## Running tests

Unit tests are the default and require no hardware.

```bash
uv run pytest                                  # full default suite
uv run pytest tests/unit                       # just the unit tests
uv run pytest tests/regression                 # concurrency / GIL regressions
uv run pytest tests/unit/test_errors.py        # single file
uv run pytest tests/unit/test_errors.py::TestHierarchy::test_cosmic_timeout_error_is_builtins_timeout_error
```

`tests/unit` and `tests/regression` are both safe to run on a bench with an adapter
attached: the one hardware-dependent regression test carries the `hardware` marker,
decides availability by enumeration alone, and takes the cross-process device lease
before it opens anything. A bare `pytest` opens no device, not even at collection.

## Quality gates

All gates must pass locally before merging. CI runs them too — `ci.yml` fires on every pull
request and on pushes to `develop-*`, `integration/**` and `main` — but run them locally
anyway: a draft PR gets the Ubuntu-only fast path, and the `hardware` tier is excluded from
every CI job by `addopts`, so a green PR says nothing about `tests/hardware/`.

| Gate                | Command                                  |
| ------------------- | ---------------------------------------- |
| Format              | `uv run ruff format --check .`           |
| Lint                | `uv run ruff check .`                    |
| Types               | `uv run mypy`                            |
| Docstrings          | `uv run interrogate -c pyproject.toml .` |
| Docstring/signature | `uv run pydoclint src/pycosmicsdk/`      |
| Tests               | `uv run pytest`                          |
| All hooks           | `uv run pre-commit run --all-files`      |

`pytest` also enforces a coverage floor (`fail_under = 70`), and a local `stub-drift`
pre-commit hook compares `_pycosmicsdk_cpp.pyi` against the built extension.

Auto-format and auto-fix:

```bash
uv run ruff format .
uv run ruff check --fix .
```

Project-wide conventions for AI agents live as individual rule files in
[`.claude/rules/`](.claude/rules/); `.claude/skills/` holds project-shipped Claude Code
skills.

## Project layout

```
src/pycosmicsdk/       # SDK source (PEP 561 typed)
  _interfaces/         # per-protocol classes: i2c, i3c, spi, uart, gpio
  legacy/              # binhosupernova / supernovacontroller / binhopulsar shims
  _cpp/                # the compiled extension installs here
native/                # C++ sources for the nanobind extension
submodules/CosmicSDK/  # C/C++ CosmicSDK as a git submodule
docs/api/              # user documentation (Sphinx source, incl. migration.md)
docs_dev/              # internal design docs, specs and hardware notes
examples/              # hardware-touching usage examples (not run by CI)
tests/                 # unit and regression test suites
```

## License

Proprietary — Copyright (c) 2026 Binho LLC. See [LICENSE](LICENSE).

The licence is a source-closed one: Binho keeps every right in its own code, and there is no
right to distribute a modified build of it. But it is not the all-rights-reserved text it used
to be, because a published wheel cannot be distributed under one. It permits installing and
using the SDK commercially, redistributing the published wheel unmodified (an internal mirror,
a build cache, a container image), modifying it for your own use, and reverse engineering it
to debug those modifications.

Those last two are there because the wheel carries third-party code that requires them.
[NOTICE](NOTICE) lists it: **libusb** under the GNU LGPL v2.1-or-later, plus **HIDAPI** and
**nanobind** taken under BSD-3-Clause, and **libudev** on some Linux builds. LGPL §6 lets a proprietary
work link an LGPL library only on terms that permit modification for the customer's own use
and reverse engineering to debug it, so `LICENSE` grants exactly that and no more. A full copy
of the LGPL ships as [LICENSE.libusb](LICENSE.libusb); libusb is dynamically linked and
replaceable, which is the §6(b) route. All three files ride in every wheel under
`dist-info/licenses/`, and `.github/scripts/check_wheels.py` refuses to publish a wheel whose
extension contains libusb without them.

Open licensing questions that need a person rather than a build change — including the ones
that need a lawyer — are collected in
[docs_dev/discussion/licensing-open-decisions.md](docs_dev/discussion/licensing-open-decisions.md).
