culvert-async 0.2.0

Async typed access to the HDMI 2.1 SCDC register map
Documentation
# Architecture

## Role

`culvert-async` is the async companion to `culvert`: typed access to the HDMI 2.1 SCDC
register map over an `hdmi_hal_async::scdc::ScdcTransport`. Its `Scdc<T>` has culvert's
methods as `async fn`s.

The register map itself is not duplicated. Every address, bit position and protocol check
lives in [`culvert::codec`], the I/O-free part of culvert; culvert-async's methods only
perform the reads and writes, `.await`ing the transport, and hand the bytes to `codec`.
The sync and async clients therefore share one register map: a fix in culvert reaches
both. The stack design document's "Sync and Async Companions" section records why.

The crate also implements `plumbob_async::ScdcClient` for `Scdc<T>`, gated behind a
`plumbob-async` cargo feature: the async counterpart to culvert's `plumbob` feature, and
the hook that connects culvert-async to `plumbob-async`'s training.

All register types and error types are re-exported from `culvert`, so callers do not need
both crates.

---

## Scope

culvert-async covers:

- `Scdc<T>`: the client, wrapping an async `ScdcTransport`, with culvert's methods as
  `async fn`s:
  - version negotiation: `read_sink_version`, `write_source_version`,
  - scrambling control: `write_tmds_config`, `read_scrambler_status`,
  - update flags: `read_update_flags`, `clear_update_flags`,
  - FRL configuration and training: `write_config_0`, `write_frl_config`,
    `read_source_test_config`, `read_status_flags`, `read_ltp_requests`,
  - error counters: `read_ced`, `read_rs_correction`,
- re-exports of culvert's public types (`ScdcError`, `ProtocolError`, `TmdsConfig`,
  `ScramblerStatus`, `Config0`, `FrlConfig`, `FfeLevels`, `FrlRate`, `SourceTestConfig`,
  `StatusFlags`, `LtpReq`, `LtpRequests`, `UpdateFlags`, `ClearableUpdateFlags`,
  `CedCount`, `CedCounters`, `RsCorrectionCount`), and of hdmi-hal-async's
  `ScdcTransport`, the bound on `Scdc<T>`,
- the `plumbob_async::ScdcClient` implementation for `Scdc<T>`, behind the `plumbob-async`
  cargo feature.

The following are out of scope:

- **Protocol logic** — register addresses, bit encoding and decoding, and protocol checks
  are `culvert::codec`'s. culvert-async adds no protocol logic.
- **Sync API** — callers on sync transports use `culvert` directly.
- **Link training state machine** — `plumbob`'s, which `plumbob-async` drives.
- **I²C / DDC transport** — platform backends implement `hdmi_hal_async::scdc::ScdcTransport`.
  culvert-async never touches I²C directly.
- **New register coverage** — registers on culvert's roadmap are added to culvert first;
  culvert-async gains the async method in its next release.

---

## Dependencies

```
display-types  ─┐
hdmi-hal       ─┼─►  culvert          ─┐
                │                      │
hdmi-hal       ─┴─►  hdmi-hal-async  ─┴─►  culvert-async  ──►  plumbob-async
```

- `culvert` — the register map (`culvert::codec`) and all shared types, re-exported here.
- `hdmi-hal-async` — the async `ScdcTransport` trait.
- `plumbob-async` (optional) — the `ScdcClient` trait that `Scdc<T>` implements. The
  feature also enables culvert's `plumbob` feature, for its type conversions.

---

## The `Scdc<T>` Client

`Scdc<T>` is culvert's `Scdc<T>` with an async transport: a thin, stateless client that
owns the transport.

```rust
impl<T: hdmi_hal_async::scdc::ScdcTransport> Scdc<T> {
    pub fn new(transport: T) -> Self;
    pub fn into_transport(self) -> T;

    pub async fn read_sink_version(&mut self) -> Result<u8, ScdcError<T::Error>>;
    pub async fn write_frl_config(&mut self, config: FrlConfig) -> Result<(), ScdcError<T::Error>>;
    pub async fn read_ltp_requests(&mut self) -> Result<LtpRequests, ScdcError<T::Error>>;
    pub async fn read_ced(&mut self) -> Result<CedCounters, ScdcError<T::Error>>;
    // … one async method per culvert method
}
```

Each method reads or writes the registers `culvert::codec` names and converts with the
matching `codec` function:

```rust
pub async fn read_ced(&mut self) -> Result<CedCounters, ScdcError<T::Error>> {
    let mut bytes = [0; 8];
    for (byte, reg) in bytes.iter_mut().zip(codec::CED_REGISTERS) {
        *byte = self.transport.read(reg).await.map_err(ScdcError::Transport)?;
    }
    Ok(codec::decode_ced(bytes))
}
```

The semantics of every method are culvert's; the only difference is that register
accesses yield to the executor rather than blocking.

---

## The `plumbob-async` Feature

culvert-async implements `plumbob_async::ScdcClient` for `Scdc<T>`, gated behind a
`plumbob-async` cargo feature.

```sh
# a crate using both
cargo add culvert-async --features plumbob-async
cargo add plumbob-async
```

Each of the eight `ScdcClient` methods awaits one culvert-async method, exactly as
culvert's `plumbob` feature calls one culvert method. The conversions between culvert's
register types and plumbob's are culvert's own `From` impls — the feature enables
`culvert/plumbob` to get them — so the sync and async adapters convert identically.

**Polling and stopping early.** `Scdc` does not wait between calls. plumbob's limits
assume one poll every 2 ms by default, and plumbob's contract puts the wait inside the two
polled methods, `read_flt_ready` and `read_update_flags`. With `Scdc<T>` that means an
`ScdcClient` of your own that wraps it, waits in those two methods and forwards the rest:

```rust
use core::time::Duration;
use culvert_async::{Scdc, ScdcError, ScdcTransport};
use plumbob_async::{ScdcClient, UpdateFlags};

/// `Scdc<T>` that waits the poll interval in the two methods plumbob polls.
struct Paced<T>(Scdc<T>);

impl<T: ScdcTransport> ScdcClient for Paced<T> {
    type Error = ScdcError<T::Error>;

    async fn read_flt_ready(&mut self) -> Result<bool, Self::Error> {
        tokio::time::sleep(Duration::from_millis(2)).await; // or the executor's timer
        ScdcClient::read_flt_ready(&mut self.0).await
    }

    async fn read_update_flags(&mut self) -> Result<UpdateFlags, Self::Error> {
        tokio::time::sleep(Duration::from_millis(2)).await;
        ScdcClient::read_update_flags(&mut self.0).await
    }

    // … the other six methods forward without waiting
}
```

A wall-clock deadline or a cancel signal goes in the same two methods, following
plumbob's "Stopping early" rule (`plumbob::ScdcClient`): return one error of your own
(the wrapper's `Error` then wraps `ScdcError`), let every later call through, and never
fail a read made after a `write_frl_config` with `NotSupported` until a `write_frl_config`
with an FRL rate starts training again. Neither belongs in the transport: a wait there
applies to every register access, not only the polls, and a transport cannot tell the
polled reads from LTS:L's, so a deadline there can fail LTS:L itself and leave the sink in
FRL. `Paced<T>` stays `Send` over a `SendScdcTransport`, as `Scdc<T>` does.

---

## Module Structure

```
src/
  lib.rs               — #![no_std], re-exports, module declarations, crate-level docs
  client/
    mod.rs             — Scdc<T>, new, into_transport
    version.rs         — read_sink_version, write_source_version
    scrambling.rs      — write_tmds_config, read_scrambler_status
    update.rs          — read_update_flags, clear_update_flags
    frl.rs             — write_config_0, write_frl_config, read_source_test_config,
                         read_status_flags, read_ltp_requests
    ced.rs             — read_ced, read_rs_correction
    plumbob_client.rs  — plumbob_async::ScdcClient impl (cfg feature = "plumbob-async")
    test_transport.rs  — async TestTransport (cfg test)
tests/
  plumbob_async.rs     — end-to-end training through Scdc<T> (cfg feature = "plumbob-async")
```

The layout matches culvert's module for module, and the client modules, their tests and
the example are culvert's with `async`/`.await`, so the two crates stay easy to compare.

---

## `no_std` Compatibility

culvert-async is `#![no_std]`. The async transport trait and all return types require no
allocator. `Scdc<T>` holds only the transport; `ScdcError<E>` is stack-allocated. The full
API is available in bare `no_std` environments, consistent with culvert and the rest of
the stack. CI builds the crate, with and without the `plumbob-async` feature, for
`thumbv7em-none-eabi`, a target without `std`.

`#![allow(async_fn_in_trait)]` is not needed in culvert-async: the crate implements async
traits but does not define them. The lint applies to trait definitions, not to implementors.

---

## `Send` futures

`Scdc<T>`'s futures are `Send` when the transport's are and the transport is `Send` and
`Sync`: a future holds `&mut Scdc<T>`, and `ScdcTransport::read` takes `&self`, so a future
that reads holds `&T` as well. A transport with a
`Cell` or `RefCell` inside is therefore fine on a single-threaded executor and cannot be
spawned on a multi-threaded one.

Code generic over the plain `ScdcTransport` cannot prove the futures `Send` at all: an
`async fn` in a trait promises nothing about its future. Generic code bounds the
transport by hdmi-hal-async's `Send` twin, `SendScdcTransport` (`Send + Sync`, with `Send`
futures and errors), and the PHY by `SendHdmiPhy`. Then training through the plain
`Scdc<T>` is `Send`: `Scdc<T>`'s `ScdcClient` implementation is concrete, so the compiler
sees through it to the transport's futures. No second client type is needed, and a test
(`tests/plumbob_async.rs`) checks this at compile time. Bound by the twins alone: an added
`+ ScdcTransport` or `+ HdmiPhy` makes the compiler use that bound instead of the blanket
implementation, and the futures are no longer known to be `Send`.

```rust
use culvert_async::{Scdc, SendScdcTransport};
use plumbob_async::{FrlTrainer, HdmiForumFrl, SendHdmiPhy, TrainingConfig};

const RATES: &[HdmiForumFrl] = &[
    HdmiForumFrl::Rate12Gbps4Lanes,
    HdmiForumFrl::Rate6Gbps4Lanes,
];

fn spawn_training<T, P>(mut trainer: FrlTrainer<Scdc<T>, P>)
where
    T: SendScdcTransport + 'static,
    P: SendHdmiPhy + 'static,
{
    tokio::spawn(async move {
        let _ = trainer.train(RATES, &TrainingConfig::default()).await;
    });
}
```

Both twins are re-exported where the code needs them: `SendScdcTransport` from
culvert-async, `SendHdmiPhy` from plumbob-async. `train_with_events` is `Send` too when its
callback is.

**Cancellation.** Every method performs at most one write and keeps no state between
calls, so a dropped future leaves `Scdc<T>` usable (plumbob-async's `exit_to_tmds` then
returns a dropped training attempt to TMDS). The multi-register reads, `read_ced` and
`read_rs_correction`, read one register at a time and can tear; their documentation says
so.

**Code bounded by `SendScdcClient`.** plumbob-async's `SendScdcClient` is the client's
twin. `Scdc<T>` implements the plain `ScdcClient` for every transport and cannot also
implement `SendScdcClient` for `Send` ones: plumbob-async's blanket implementation would
give it `ScdcClient` twice (E0119). Code bounded by `C: SendScdcClient` (an integration
layer generic over the client, for example) therefore cannot take `Scdc<T>` directly. A
local wrapper that forwards each method can:

```rust
struct SendScdc<T>(Scdc<T>);

impl<T: SendScdcTransport> SendScdcClient for SendScdc<T> {
    type Error = ScdcError<T::Error>;

    async fn read_flt_ready(&mut self) -> Result<bool, Self::Error> {
        ScdcClient::read_flt_ready(&mut self.0).await
    }
    // … the other seven methods alike
}
```

culvert-async does not ship that wrapper yet; it will when a consumer generic over
`SendScdcClient` needs it, or when return-type notation is decided against (see
`doc/roadmap.md`; recorded in the stack design document, "Sync and Async Companions").

---

## Design Principles

The principles are culvert's:

- **One register map.** Addresses, bit positions and protocol checks are `culvert::codec`'s;
  culvert-async adds only the async I/O, so it cannot drift from culvert.
- **Type reuse over duplication.** All register types and error types live in `culvert`
  and are re-exported here.
- **Stateless client, stateful caller.** `Scdc<T>` holds no protocol state. The async
  nature of the transport does not change this.
- **Deterministic and testable.** The async `TestTransport` supports the same pre-loaded
  register array and `failing_after` pattern as culvert's sync version. No hardware required.
- **Interface owned by the consumer.** `plumbob_async::ScdcClient` is defined in
  `plumbob-async`; culvert-async implements it. The cargo feature gates the impl so
  culvert-async remains independently usable without the training layer as a dependency.
- **No unsafe code.** `#![forbid(unsafe_code)]`.
- **Parity with culvert.** When culvert adds a method or register group, culvert-async
  adds its async counterpart in its next release.
- **Attested releases.** From 0.2.0, every release is published through a GitHub Actions
  workflow, with crates.io trusted publishing, that signs the `.crate` package with
  [SLSA Build Level 2]https://slsa.dev provenance. Verify with
  `gh attestation verify <file> --repo DracoWhitefire/culvert-async`. 0.1.0 was uploaded
  by hand, without provenance.

[`culvert::codec`]: https://docs.rs/culvert/latest/culvert/codec/index.html