# Testing without hardware `pycosmicsdk.testing` lets you write tests for code that drives a Binho host adapter without owning one. Open a fake adapter, say what its commands should do, and drive it with the ordinary SDK API: ```python from pycosmicsdk import I2cPullUp from pycosmicsdk.testing import FakeAdapter def test_reads_the_id_register() -> None: adapter = FakeAdapter() adapter.returns("i2c_read", b"\x2a") with adapter.open() as dev: i2c = dev.i2c().initialize(frequency_hz=400_000, pull_up=I2cPullUp.OHM_330) assert i2c.read(address=0x50, subaddress=b"\x10", length=1) == b"\x2a" assert adapter.calls_to("i2c_read")[0].args[1] == 0x50 ``` Install the package as usual; there is nothing extra to add to your test dependencies. ## What is real and what is faked {py:obj}`~pycosmicsdk.testing.FakeAdapter.open` returns a real {py:obj}`~pycosmicsdk.Device` and {py:obj}`~pycosmicsdk.testing.FakeAsyncAdapter.open` a real {py:obj}`~pycosmicsdk.AsyncDevice`. Only the compiled facade underneath is substituted, so everything above it is the shipping code: | Real | Faked | |---|---| | The device lifecycle and its state machine | The USB HID transport | | All seven interface classes and their async twins | The firmware's command handling | | Argument validation and {py:obj}`~pycosmicsdk.ValidationError` | The bytes on the wire | | The notification dispatcher and {py:obj}`~pycosmicsdk.Subscription` filters | Notification arrival | | The asyncio request/response correlation engine | | | The status-to-exception translation | | A test that passes against the fake exercises the same objects a test against an adapter on the bench does. ## Programming commands Commands are named by **facade method** — `"i2c_read"`, `"spi_transfer"`, `"i3c_ccc_getpid"` — not by public method name, because one public call may issue more than one command. Programming a name the facade does not carry raises {py:obj}`~pycosmicsdk.testing.FakeAdapterError` on the spot, so a typo cannot quietly do nothing. `returns` : Succeed with a value. The value is the *logical* result — the `bytes` a read yields, the `int` a voltage reader reports — and the fake re-encodes it for whichever surface asked, so the same call programs the blocking and the asyncio adapter identically. `fails` : Report a firmware status. The status travels the real translator, so the exception is the same class with the same `status_code` and `operation` that hardware would have produced. `responds` : Compute the outcome from the call — a register map, a counter, a value derived from the address the caller passed. ```python adapter.returns("i2c_read", b"\x2a") adapter.returns("i2c_scan_bus", [0x50, 0x68]) # a scan that finds two devices adapter.fails("i2c_write", StatusCode.FW_I2C_NACK_ADDRESS) registers = {0x0F: b"\x6b", 0x20: b"\x01"} adapter.responds("i2c_read", lambda call: registers[call.args[2][0]]) ``` All three take `times=`. Without it they install a standing default; with it they queue that many one-shot outcomes, taken in order ahead of the default — which is how you write the transient-failure case: ```python adapter.fails("i2c_write", StatusCode.FW_I2C_NACK_ADDRESS, times=1) # NACK once, then succeed ``` A `responds` handler reports a failure by returning a {py:obj}`~pycosmicsdk.StatusCode` other than `SUCCESS`; anything else it returns is a result. A command nobody programmed succeeds with a plausible default: zero bytes of the requested length for a read, an empty scan, the firmware default for a length limit. ## Asserting on the conversation {py:obj}`~pycosmicsdk.testing.FakeAdapter.calls` is every facade call the SDK made, oldest first, and {py:obj}`~pycosmicsdk.testing.FakeAdapter.calls_to` filters it by method. Each entry is a {py:obj}`~pycosmicsdk.testing.RecordedCall` whose `args` are plain Python values — enums unwrapped to their `int`, payloads as `bytes`, and on the asyncio surface with the transaction id already stripped, so the two surfaces record identically. ```python write = adapter.calls_to("i2c_write")[0] assert write.args == (I2cBus.A.value, 0x50, b"\x10", b"\x01", False) ``` ## Notifications Real notifications arrive on the dispatcher thread. Build one with the helpers in this package and hand it to {py:obj}`~pycosmicsdk.testing.FakeAdapter.publish`, which returns only once the dispatcher has finished fanning it out — so no test sleeps and no test polls: ```python from pycosmicsdk.testing import gpio_interrupt, i3c_ibi, i3c_target_bus_event, uart_rx with adapter.open() as dev: seen = [] dev.subscribe(I3cIbiNotification, lambda n: seen.append(n.address)) adapter.publish(i3c_ibi(address=0x08, payload=b"\x01")) assert seen == [0x08] # already delivered ``` The builders cover every event type the SDK reports: {py:obj}`~pycosmicsdk.testing.uart_rx`, {py:obj}`~pycosmicsdk.testing.i3c_ibi`, {py:obj}`~pycosmicsdk.testing.i3c_hot_join`, {py:obj}`~pycosmicsdk.testing.i3c_target_bus_event`, {py:obj}`~pycosmicsdk.testing.gpio_interrupt`, {py:obj}`~pycosmicsdk.testing.i3c_connector_event` and {py:obj}`~pycosmicsdk.testing.unknown_notification`. Each produces a real notification object, so `isinstance` filtering, `where=` predicates, {py:obj}`~pycosmicsdk.Uart.receive` and {py:obj}`~pycosmicsdk.I3cTarget.wait_for_event` all see it as they see a firmware event. Publishing is rejected with {py:obj}`~pycosmicsdk.testing.FakeAdapterError` when nothing is listening yet — subscribe, or bring up the interface that arms reception, first. A silently dropped event would only show up later as a confusing assertion failure. ## The asyncio surface {py:obj}`~pycosmicsdk.testing.FakeAsyncAdapter` is the twin: the same programming calls, the same values, an opener that works as `async with adapter.open() as dev:` or `dev = await adapter.open()`, and `await adapter.publish(...)` as the notification barrier. ```python async def test_scan_finds_the_bench_targets() -> None: adapter = FakeAsyncAdapter() adapter.returns("i2c_scan_bus", [0x50, 0x68]) async with adapter.open() as dev: i2c = await dev.i2c().initialize(frequency_hz=400_000, pull_up=I2cPullUp.OHM_330) found = await i2c.scan() assert sorted(found.addresses_7bit) == [0x50, 0x68] ``` Responses reach the correlation engine inside the submit call, so an awaited command resolves on the next event-loop pass and no wall-clock time passes. A timeout is programmed like any other status — `adapter.fails("i2c_read", StatusCode.SDK_ERROR_TIMEOUT)` raises {py:obj}`~pycosmicsdk.CosmicTimeoutError` immediately rather than after the per-request deadline. ## The bulk plane, and why a fake adapter has none [The bulk transfer plane](bulk.md) is the narrative account of what this section fakes; [Target mode](target-mode.md) covers the two arenas that have no other transport. Large SPI payloads, large I2C and I3C controller transfers, and the I2C and SPI target arenas do not travel over the same USB pipe as ordinary commands. They use a **second, vendor-specific bulk transport** that the host has to claim when it connects — bound to WinUSB on Windows, resting on udev permissions elsewhere — and a host that never claimed it has no bulk plane whatever the connected device supports. On top of that the device is asked, once per connection, which bulk commands it implements and how large a payload each protocol group accepts; firmware that predates the question does not answer, and silence is read as *supports nothing*. **A `FakeAdapter` reports no bulk plane, and that is the case worth testing.** It is what most real hosts report, so the SPI fallback is the path your code will actually take: ```python adapter = FakeAdapter() with adapter.open() as dev: spi = dev.spi().initialize(...) spi.write(b"\x01\x02\x03") # HID transport, as on a host with no bulk assert not dev.capabilities.supports_spi_bulk() ``` Two behaviours of the real negotiation carry over exactly: - **It is asked once and never retried.** The SDK records the query as done *before* issuing it, so even a device that never answers costs one query per connection and every later call is free. A test that needs the negotiation to run again must close and reopen the device. - **Demanding the plane fails before anything is sent.** `spi.write(..., transport=SpiTransport.BULK)` raises {py:obj}`~pycosmicsdk.CapabilityError` on a host with no bulk plane, and no command reaches the adapter. To exercise the *other* branch — a host that did claim the interface talking to a device that advertises bulk — describe the pair with {py:obj}`~pycosmicsdk.testing.BulkProfile` and hand it to {py:obj}`~pycosmicsdk.testing.bulk_facade`: ```python from pycosmicsdk._capabilities import probe_bulk from pycosmicsdk.testing import ( BulkGroup, BulkProfile, bulk_capabilities, bulk_facade, ) profile = BulkProfile( transport_open=True, # this host claimed the vendor interface capabilities=bulk_capabilities(groups={BulkGroup.SPI: 32_768}), ) assert probe_bulk(bulk_facade(profile)).max_spi_payload_bytes == 32_768 ``` `transport_open` defaults to `False`, because that is what almost every real host reports and it is the branch most tests should exercise. Set it only when the has-bulk path is the thing under test. `bulk_capabilities` takes a payload ceiling per protocol group and works out the command mask; pass `commands=` to fake a device that implements only part of a group's bulk surface, and `supported=False` for one that answered but said no. {py:obj}`~pycosmicsdk.testing.no_bulk_capabilities` is the other reply — what a device with no bulk plane at all yields. To model what the *device* advertises rather than what this SDK binds — which is not the same list, since firmware carries bulk commands the SDK has no wrapper for — use {py:obj}`~pycosmicsdk.testing.firmware_bulk_capabilities`. `BulkProfile` exists because "has a bulk plane" is two independent facts, and the interesting case is when they disagree. `transport_open` is whether *this host* claimed the vendor interface; `webusb_enabled` is whether the interface is in the *device's* configuration descriptor at all. A device with `webusb_enabled=False` still answers the capability query saying it supports bulk — firmware reports support unconditionally — so that combination is the one a real host actually meets, and it is worth a test: ```python absent = BulkProfile( webusb_enabled=False, capabilities=bulk_capabilities(groups={BulkGroup.SPI: 32_768}), ) negotiated = probe_bulk(bulk_facade(absent)) assert negotiated.device_supported # the device said yes assert not negotiated.host_transport_open # the endpoints are not there assert not negotiated.available ``` Keeping those two flags apart is the point: it is what lets a caller tell "this adapter cannot" from "this machine cannot", which need different fixes. The ceiling is **per call, and per protocol group**. It is not the same number as a target's arena capacity — though on these products it happens to be, since firmware derives both from one constant per model: {py:obj}`~pycosmicsdk.testing.SUPERNOVA_BULK_ARENA_BYTES` is 32768 and {py:obj}`~pycosmicsdk.testing.PULSAR_BULK_ARENA_BYTES` is 16384. Prefer those names to literals; the Pulsar figure in particular moved several times during development, and tests written against an older number pass for the wrong reason. ## Target mode and the memory arena In target mode the adapter exposes a block of memory that a controller on the bus reads and writes, and that your code preloads and reads back with a pair of commands. Those two commands are the **host's own window** onto memory the firmware holds — not bus traffic — so a read after a write returns what was written even with nothing attached. That makes it the one piece of target-mode behaviour a test can rely on without hardware, and {py:obj}`~pycosmicsdk.testing.memory_arena` provides it: ```python from pycosmicsdk.testing import FakeAdapter, memory_arena adapter = FakeAdapter() arena = memory_arena(1024) adapter.responds("i3c_target_write_memory", arena.on_write) adapter.responds("i3c_target_read_memory", arena.on_read) with adapter.open() as dev: target = dev.i3c_target().initialize(...) target.write_memory(address=0x10, data=b"\xde\xad") assert target.read_memory(address=0x10, length=2) == b"\xde\xad" ``` `arena.preload(address, data)` seeds the starting state without it showing up in `adapter.calls`, and `arena.contents` is the whole block for asserting on what the code under test stored. The same arena serves every protocol, because each takes the same two positional arguments. Pass `register_bytes=` when the target's addresses are register indices rather than byte offsets — the I3C target's memory layout selects 1, 2 or 4 — and `fill=0xFF` for an arena that starts out erased. It is a flat block of bytes and nothing more: no emulation models, no page wrapping, no busy windows, no write-protect windows, no per-region access policies. A transfer running off the end raises {py:obj}`~pycosmicsdk.testing.FakeAdapterError` rather than guessing a status, because which one firmware reports differs per protocol — pass `overflow=StatusCode....` when that case is what the test is about. **Re-arming a target is not the same call on every protocol**, and this is the one to get right: | Target | A second `initialize()` | The idempotent way to reconfigure | |---|---|---| | I3C | **Succeeds.** Re-initialises the peripheral unconditionally, and takes the role from the controller when it has to. | Either — `initialize()` or `set_parameters()`. | | I2C | Reports `FW_INTERFACE_ALREADY_INITIALIZED`. | `set_parameters()`, which keeps the arena contents. | | SPI | Reports `FW_INTERFACE_ALREADY_INITIALIZED`. | `set_parameters()`, which keeps the arena contents. | The fake enforces exactly that split. Reading the I3C rule across to the other two — or the reverse — is how a test comes to assert the opposite of what hardware does. ## What the fake rejects A fake that accepts everything is worse than useless, so these fail here for the same reason and with the same exception they would on hardware: - **A call before `initialize()`** reports `FW_INTERFACE_NOT_INITIALIZED` as a {py:obj}`~pycosmicsdk.FirmwareError`. Bring the interface up in the test, as you would against hardware. GPIO is the exception, as it is on the device: it has no interface-level bring-up, and the SDK already rejects an unconfigured pin in Python. - **A second `initialize()`** reports `FW_INTERFACE_ALREADY_INITIALIZED` — for I2C, UART, SPI and the I3C *controller*, which are the four bring-ups firmware refuses a repeat of today. The I2C and SPI *target* bring-ups arriving with the target-mode surface refuse a repeat too, and offer a separate `set_parameters` command as the idempotent path; the I3C target bring-up is the odd one out and does not. See [Target mode](#target-mode-and-the-memory-arena). - **A call after `close()`, or after `reboot()`** raises {py:obj}`~pycosmicsdk.DisconnectError` — that is the real {py:obj}`~pycosmicsdk.Device` state machine, not something the fake adds. - **A protocol the model does not have** raises {py:obj}`~pycosmicsdk.CapabilityError`. `FakeAdapter(model=DeviceModel.PULSAR)` refuses {py:obj}`~pycosmicsdk.Device.i3c` and offers two I2C buses, which come up independently. - **Bad arguments** raise {py:obj}`~pycosmicsdk.ValidationError` before the fake is reached at all, because that check is the SDK's own. ## What it deliberately accepts The mirror image of the list above, and the more important half: a fake that invents a refusal is worse than one that permits too much, because it has you writing code around a case that cannot happen. Each of these succeeds here *because it succeeds on the device*, and each is sourced to upstream's `@retval` documentation and to the firmware handler that answers the command: - **A second {py:obj}`~pycosmicsdk.I3cTarget.initialize`.** Target bring-up is idempotent. Upstream states that `i3cTargetInit` re-initialises the I3C peripheral unconditionally and *never* reports `FW_INTERFACE_ALREADY_INITIALIZED`, and the firmware handler inspects no state before doing it. Re-arming a target mid-test is the ordinary case, not an error. - **Bringing the I3C controller up after target mode.** The peripheral holds one role, and the target bring-up took it, so the next {py:obj}`~pycosmicsdk.I3cController.initialize` is not a repeat. - **{py:obj}`~pycosmicsdk.I3cController.get_device_table` and {py:obj}`~pycosmicsdk.I3cController.set_target_configuration` before bring-up.** Both are answered from the controller's internal target table rather than from the bus, and neither is gated on the interface state. A table read before bring-up reports an empty table. - **Every voltage-rail command before bring-up.** They are system commands, and servicing them first is what makes power-then-initialise work. ## What it deliberately does not fake The fake is a programmable stand-in, not a device simulator. It has no model of the bus, so nothing below is reproduced: - **Enumeration and the connect handshake.** `adapter.open()` does no USB enumeration, so it never raises {py:obj}`~pycosmicsdk.DeviceNotFoundError`, and {py:obj}`~pycosmicsdk.list_devices` still reports the real machine. - **Bus semantics.** A write is not remembered, so a following read does not return it unless you programmed it that way (`responds` over a dict is the usual answer). Addresses, voltages and clock rates are recorded, never simulated. - **Firmware parameter validation.** A frequency or payload size the firmware would reject is accepted; program the rejection with `fails` when that is what the test is about. - **I3C mode exclusivity.** One adapter has one I3C peripheral in one role, and firmware answers a controller command taken in target mode — or a target command taken in controller mode — with `FW_INVALID_COMMAND`. Here the controller and target families are tracked independently, so both can be up at once and either can be driven. Only bring-up is role-accurate: an I3C controller `initialize` after a target `initialize` succeeds, as it does on the device. A test that must pin the cross-role refusal should program it with `adapter.fails(..., StatusCode.FW_INVALID_COMMAND)`. - **I2C and SPI role exclusivity, which is settled and still not modelled.** `FW_I2C_ROLE_CONFLICT` (`0x0216`) and `FW_SPI_ROLE_CONFLICT` (`0x0316`) are real and are returned from six firmware guard sites, so the fake is no longer declining to choose between two authorities. **It permits both orders**, for two better reasons: on the device the exclusivity is **recoverable** — `I2C_DEINIT` and `SPI_DEINIT` release the role and the shared target arena, and {py:obj}`I2cTarget.release ` / {py:obj}`SpiTarget.release ` send them — so a refusal here would model a terminal state the hardware does not have; and a refusal would break the controller-then-target pattern with nothing to offer in its place. **The third reason this entry used to give is superseded**, and is named because a stale reading would keep it: *"this SDK cannot issue that recovery, because no CosmicSDK revision binds an `i2cDeinit` or `spiDeinit`, so from Python a role taken is held for the session"*. It can, and it is not. Programme the conflict when it is what the test is about — `adapter.fails("spi_controller_init", 0x0316)` reaches it, and `pycosmicsdk.testing.SPI_ROLE_CONFLICT` names the number. Which way round matters: the I2C guards are order-sensitive, because a target bring-up is refused by any role on *either* bus while a controller bring-up looks only at its own. Still do not read a passing test here as evidence that a controller-then-target sequence works on hardware — the fake permits an order the device refuses, and the device's refusal is what a `release()` clears. - **Firmware's GPIO pin state.** `FW_GPIO_NOT_CONFIGURED` and `FW_GPIO_WRONG_CONFIGURATION` are never reached, because {py:obj}`~pycosmicsdk.Gpio` refuses an unconfigured or wrong-direction pin in Python first — which is the SDK's own behaviour, not the fake's, so the two agree. - **Firmware's target-table lookup.** {py:obj}`~pycosmicsdk.I3cController.set_target_configuration` answers `FW_TARGET_ADDRESS_NOT_FOUND_IN_THE_TABLE` on the device for an address the controller does not know. The fake keeps no table, so it accepts any address. - **The UART's VCOM mode.** On a device whose CDC interface is enabled in UART passthrough and has been claimed by the host's serial port, firmware answers `uart_init` with `FW_UART_IN_VCOM_PORT_MODE`. There is no host serial port here, so UART bring-up always sees an idle peripheral. Program the refusal with `fails` if that is the case under test. - **Timing and concurrency.** Commands complete instantly, notifications arrive exactly when published, and nothing is reordered. That is the point, but it means the fake cannot surface a race that only real latency would. Nothing here needs hardware, so these tests belong in the ordinary unit-test job. The hardware-only behaviours above stay the province of tests run against an attached adapter. ## Reference ```{eval-rst} .. autoclass:: pycosmicsdk.testing.FakeAdapter :members: :inherited-members: .. autoclass:: pycosmicsdk.testing.FakeAsyncAdapter :members: :inherited-members: .. autoclass:: pycosmicsdk.testing.RecordedCall .. autoclass:: pycosmicsdk.testing.I2cScanFound .. autoclass:: pycosmicsdk.testing.MemoryArena :members: .. autofunction:: pycosmicsdk.testing.memory_arena .. autofunction:: pycosmicsdk.testing.bulk_capabilities .. autofunction:: pycosmicsdk.testing.no_bulk_capabilities .. autoclass:: pycosmicsdk.testing.BulkGroup :members: .. autoclass:: pycosmicsdk.testing.BulkCommand :members: .. autoexception:: pycosmicsdk.testing.FakeAdapterError .. autofunction:: pycosmicsdk.testing.uart_rx .. autofunction:: pycosmicsdk.testing.uart_rx_frame .. autofunction:: pycosmicsdk.testing.i3c_ibi .. autofunction:: pycosmicsdk.testing.i3c_hot_join .. autofunction:: pycosmicsdk.testing.i3c_target_bus_event .. autofunction:: pycosmicsdk.testing.gpio_interrupt .. autofunction:: pycosmicsdk.testing.i3c_connector_event .. autofunction:: pycosmicsdk.testing.unknown_notification ```