plumbob 0.3.1

FRL link training state machine for HDMI 2.1
Documentation
# plumbob

[![CI](https://github.com/DracoWhitefire/plumbob/actions/workflows/ci.yml/badge.svg)](https://github.com/DracoWhitefire/plumbob/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/plumbob.svg)](https://crates.io/crates/plumbob)
[![docs.rs](https://docs.rs/plumbob/badge.svg)](https://docs.rs/plumbob)
[![License: MPL-2.0](https://img.shields.io/badge/license-MPL--2.0-blue.svg)](LICENSE)
[![Rust 1.85+](https://img.shields.io/badge/rustc-1.85+-orange.svg)](https://blog.rust-lang.org/2025/02/20/Rust-1.85.0.html)
[![SLSA Level 2](https://slsa.dev/images/gh-badge-level2.svg)](https://slsa.dev)
[![trusted publishing only](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fcrates.io%2Fapi%2Fv1%2Fcrates%2Fplumbob&query=%24.crate.trustpub_only&label=trusted%20publishing%20only)](#verifying-releases)

FRL link training state machine for HDMI 2.1.

`plumbob` implements the source side of Fixed Rate Link (FRL) training as defined in the
HDMI 2.1 specification. It owns the link training state machine — LTS:2 (prepare),
LTS:3 (train), LTS:P (pass), LTS:4 (lower rate) and LTS:L (exit to TMDS) — and defines
the `ScdcClient` interface its SCDC implementation must satisfy. Callers supply an
`ScdcClient` and an `HdmiPhy`, call `train` with the rates to try, and handle the
`TrainingOutcome`.

Which rates to try, SCDC register decoding, and PHY vendor sequences are all out of
scope: plumbob implements the spec, not the strategy around it.

## Usage

```sh
cargo add plumbob
```

Implement `ScdcClient` against your SCDC transport, one method per register operation:

```rust
use plumbob::{
    CedCounters, ClearableUpdateFlags, FrlConfig, LtpRequests, ScdcClient, SourceTestConfig,
    UpdateFlags,
};

struct MyScdcClient { /* I²C / DDC transport */ }

impl ScdcClient for MyScdcClient {
    type Error = MyError;

    // Status_Flags_0 bit 6. Polled in LTS:2: wait the poll interval here.
    fn read_flt_ready(&mut self) -> Result<bool, MyError> { todo!() }
    // Update_0, all seven flags. Polled in LTS:3 and LTS:P.
    fn read_update_flags(&mut self) -> Result<UpdateFlags, MyError> { todo!() }
    // Write 1 to clear the given Update_0 flags (never RR_Test).
    fn clear_update_flags(&mut self, flags: ClearableUpdateFlags) -> Result<(), MyError> { todo!() }
    // Status_Flags_1/2: the per-lane requests (0x9–0xD as LtpReq::Reserved).
    fn read_ltp_requests(&mut self) -> Result<LtpRequests, MyError> { todo!() }
    // Source_Test_Configuration: all six bits (FLT_no_timeout acted on, TxFFE passed on).
    fn read_source_test_config(&mut self) -> Result<SourceTestConfig, MyError> { todo!() }
    // Config_0: read requests disabled, FLT_no_retrain clear.
    fn write_config_0_defaults(&mut self) -> Result<(), MyError> { todo!() }
    // Config_1: FRL rate and FFE levels.
    fn write_frl_config(&mut self, config: FrlConfig) -> Result<(), MyError> { todo!() }
    // ERR_DET counters, for diagnostics.
    fn read_ced(&mut self) -> Result<CedCounters, MyError> { todo!() }
}
```

Construct an `FrlTrainer` and train over the rates to try, highest first. plumbob steps
down through the list when the sink asks for a lower rate:

```rust
use plumbob::{FrlOutput, FrlTrainer, HdmiForumFrl, HdmiPhy, TrainingConfig, TrainingOutcome};

let mut trainer = FrlTrainer::new(scdc, phy);
let config = TrainingConfig::default();

let rates = [
    HdmiForumFrl::Rate12Gbps4Lanes,
    HdmiForumFrl::Rate10Gbps4Lanes,
    HdmiForumFrl::Rate6Gbps4Lanes,
];

let trained = trainer.train(&rates, &config)?;
match trained.outcome {
    TrainingOutcome::Success { achieved_rate, .. } => {
        println!("Trained at {achieved_rate:?}");
        trainer.phy_mut().set_frl_output(FrlOutput::Active)?; // start video
    }
    TrainingOutcome::FallbackRequired { reason, .. } => {
        println!("No FRL link ({reason:?}); the sink is back in TMDS");
    }
    _ => {}
}
for warning in trained.iter_warnings() {
    println!("Warning: {warning:?}");
}
```

`train` returns a `Trained`: the outcome, and the `TrainingWarning`s the attempt
produced — non-fatal anomalies such as a sink requesting an undefined pattern value,
which plumbob ignores. They do not change the outcome; they are there so that what the
sink did is visible without a trace. Read them with `warnings()` (a slice) or
`iter_warnings()`; the API is the same with and without the `alloc` feature, which only
changes the private storage (a `Vec`, or `MAX_WARNINGS` inline slots).

To take a trained link down again — the display is disabled or unplugged, or a mode
change is coming — call `trainer.exit_to_tmds()`, which runs LTS:L on its own.

For a complete worked example with simulated SCDC and PHY backends, see
[`examples/simulate`](examples/simulate/).

## Training procedure

`train` runs until a terminal state and returns a typed result. `FallbackRequired` means
no listed rate trained, with a `FallbackReason`; it always leaves the sink and PHY in
TMDS. `TrainingError` means a hard I/O failure from the SCDC client or PHY. The two are
kept distinct so a caller diagnosing a failure knows whether it came from the protocol or
the bus. After an I/O error plumbob still returns both ends to TMDS, and the error's
`exit` field (`TmdsExit`) says whether that worked; set
`TrainingConfig::exit_to_tmds_on_error` to `false` to leave them as the error left them.
While the sink sets `FLT_no_timeout` (compliance testing) an error leaves the link as it
is too, for the test equipment (`TmdsExit::HeldUnderTest`).

The rates must be FRL rates, each strictly lower than the one before it: LTS:4 steps
down. Any other list is rejected with `TrainingError::InvalidRates` before anything is
done, and an empty one with `TrainingError::NoRates`. With no FRL rate to try, don't
train; call `exit_to_tmds` if an earlier FRL link must come down.

```mermaid
flowchart TD
    A["train(rates, config)"]
    P2["LTS:2 — prepare\npoll FLT_ready · set_frl_rate\nwrite Config_0 / Config_1"]
    P3["LTS:3 — train\nfollow per-lane requests\nsend_ltp · adjust_equalization"]
    PP["LTS:P — pass\ngap characters · poll FRL_start"]
    P4["LTS:4 — lower rate\nnext rate in the list"]
    PL["LTS:L — exit to TMDS"]
    S(["Success { achieved_rate }"])
    F(["FallbackRequired { reason }"])
    H(["NoTimeoutHold { rate }"])

    A --> P2
    P2 -- FLT_ready --> P3
    P2 -- timeout --> PL
    P3 -- "all lanes 0x0" --> PP
    P3 -- "all lanes 0xF" --> P4
    P3 -- timeout --> PL
    P4 -- next rate --> P3
    P4 -- "list exhausted" --> PL
    PP -- FRL_start --> S
    PP -- "FLT_update (retrain)" --> P3
    PP -- "timeout or retrains exhausted" --> PL
    PL --> F
    P2 & P3 & PP -. "FLT_no_timeout: cap reached" .-> H
```

**LTS:2** waits for the sink to assert `FLT_ready`, configures the PHY for the rate (any
transmitter bring-up is the PHY's, inside `set_frl_rate`), then resets every lane's TxFFE
level, switches to gap characters with no pattern and writes `Config_0` and `Config_1`.

**LTS:3** follows the sink's per-lane requests: each lane carries the pattern it asks
for, 0xE raises that lane's TxFFE level (up to the advertised maximum, then held), and
the full per-lane set is sent to the PHY after every `FLT_update`. All lanes reporting
0x0 passes training; all lanes reporting 0xF asks for a lower rate.

**LTS:P** sends gap characters until the sink sets `FRL_start` (success) or `FLT_update`
(retrain, up to `TrainingConfig::max_retrains` times per call); if it sets both at once,
the retrain wins. After `Success`, the caller starts video with
`trainer.phy_mut().set_frl_output(FrlOutput::Active)`. The sink can still request
retraining during active video by setting `FLT_update`; plumbob does not watch for it.
The caller polls `Update_0` (the Xilinx driver checks every 250 ms) and calls `train`
again when it is set.

**LTS:4** moves to the next rate in the list and continues LTS:3 there. **LTS:L**
returns both ends to TMDS before any `FallbackRequired`, and after an SCDC or PHY error.

Poll limits are exact counts: N means exactly N polls before the state gives up. The
defaults (50 / 100 / 100 polls) reproduce the spec's 100 ms and 200 ms timeouts, and the
200 ms `FRL_start` wait of the AMD and Intel drivers, at one poll every 2 ms. The
inter-poll delay is the implementer's responsibility and belongs inside the polled
`ScdcClient` methods. When the sink sets `FLT_no_timeout` it is under compliance test: the
LTS:2, LTS:3 and LTS:P limits are replaced by `TrainingConfig::no_timeout_poll_cap`
(default 500 polls), and when that runs out `train` returns `NoTimeoutHold { rate }`
without leaving FRL — the test equipment is in control, and the caller decides what
comes next. Retraining from LTS:P is bounded by `TrainingConfig::max_retrains`
(default 3); the next request after that ends the attempt with `RetrainsExhausted`.
Every state is bounded, so `train` always returns: with the defaults, one call polls at
most 850–1350 times (≈ 1.7–2.7 s at 2 ms per poll, for 1–6 rates), or 4500–7000 times
(≈ 9–14 s) while the sink sets `FLT_no_timeout`. `doc/architecture.md` gives the formula.
The polled methods must actually wait the interval, or every limit shrinks in proportion.

See [`doc/architecture.md`](doc/architecture.md) for the procedure step by step.

## Diagnostics

Enable the `alloc` feature to get `train_traced` (and `train_at_rate_traced`), which
return a `TrainingTrace` alongside the result, whether that is an outcome or an error.
The trace records the rates, the `TrainingConfig` in force, and an ordered
`TrainingEvent` log covering the full attempt:

```rust
let (result, trace) = trainer.train_traced(&rates, &config);

println!("Result: {result:?}");
for event in &trace.events {
    println!("  {event:?}");
}
```

Without an allocator, `train_with_events` delivers the same events as they occur, to a
callback; the caller decides what to keep:

```rust
let mut timeouts = 0;
let trained = trainer.train_with_events(&rates, &config, &mut |event| {
    if matches!(event, TrainingEvent::TrainingTimeout { .. }) {
        timeouts += 1;
    }
})?;
```

A successful attempt, where the sink asks for one TxFFE raise on lane 1:

```
SourceTestConfigRead { config: SourceTestConfig { txffe_pre_shoot_only: false, txffe_de_emphasis_only: false, txffe_no_ffe: false, flt_no_timeout: false, dsc_frl_max: false, frl_max: false } }
FltReady { after_polls: 3 }
RateConfigured { rate: Rate12Gbps4Lanes, ffe_levels: FfeLevels(3) }
LtpRequested { requests: LtpRequests { lane0: Lfsr0, lane1: Lfsr1, lane2: Lfsr2, lane3: Lfsr3 } }
LtpRequested { requests: LtpRequests { lane0: None, lane1: FfeChange, lane2: None, lane3: None } }
FfeRaised { lane: 1, level: 1 }
TrainingPassed { after_polls: 41 }
FrlStart { after_polls: 6 }
```

A sink that asks for a lower rate, trained over `[Rate12Gbps4Lanes, Rate10Gbps4Lanes]`:

```
SourceTestConfigRead { config: SourceTestConfig { txffe_pre_shoot_only: false, txffe_de_emphasis_only: false, txffe_no_ffe: false, flt_no_timeout: false, dsc_frl_max: false, frl_max: false } }
FltReady { after_polls: 2 }
RateConfigured { rate: Rate12Gbps4Lanes, ffe_levels: FfeLevels(3) }
LtpRequested { requests: LtpRequests { lane0: RateChange, lane1: RateChange, lane2: RateChange, lane3: RateChange } }
RateLowered { from: Rate12Gbps4Lanes, to: Rate10Gbps4Lanes }
RateConfigured { rate: Rate10Gbps4Lanes, ffe_levels: FfeLevels(3) }
LtpRequested { requests: LtpRequests { lane0: Lfsr0, lane1: Lfsr0, lane2: Lfsr0, lane3: Lfsr0 } }
TrainingPassed { after_polls: 12 }
FrlStart { after_polls: 4 }
```

Poll counts include the poll that saw the event, so they read directly against the
limits in `trace.config`. A timeout in LTS:2 means the sink did not prepare at this rate;
one in LTS:3 means lanes did not converge (signal integrity or equalization); one in
LTS:P means training passed but the sink never released the link. Every
`FallbackRequired` trace ends with `ExitedToTmds`; if LTS:L fails, the trace ends with
`ExitToTmdsFailed` and `train` returns `TrainingError::ExitFailed`.

## Async and custom drivers

The state machine is written once, as `plumbob::lts::run`: a function returning a future,
over the `TrainingIo` trait (the SCDC and PHY operations training performs), which does no
I/O of its own. `FrlTrainer` drives it synchronously over an `ScdcClient` and an
`HdmiPhy`, with no async runtime: its I/O completes immediately, so the function never
waits. [`plumbob-async`](https://crates.io/crates/plumbob-async) drives the same function
over async I/O, so sync and async training cannot drift apart. Implementing `TrainingIo`
yourself drives it from any other environment.

## Features

| Feature | Default | Description |
|---------|---------|-------------|
| `std`   | no      | Implies `alloc`; no additional API surface |
| `alloc` | no      | Enables `TrainingTrace`, `train_traced` and `train_at_rate_traced` (`train_with_events` needs no feature) |

No features are enabled by default. The bare crate provides the full training state
machine without an allocator.

## `no_std` builds

`plumbob` declares `#![no_std]` throughout.

**Bare `no_std` (no features)** — the complete training state machine is available.
`FrlTrainer`, `TrainingConfig`, `TrainingOutcome`, `TrainingError`, and all owned protocol
types (`LtpReq`, `LtpRequests`, `FfeLevels`, `UpdateFlags`, `CedCounters`, …) are
stack-allocated. No heap use anywhere in the training loop. This tier covers bare-metal
and firmware targets.

**`no_std` + `alloc`** — adds `TrainingTrace`, `train_traced` and `train_at_rate_traced`:

```sh
cargo add plumbob --features alloc
```

**`std`** — implies `alloc`:

```sh
cargo add plumbob --features std
```

## Stack position

`plumbob` sits between the SCDC/PHY implementations and the integration layer that
will orchestrate rate selection and fallback. It defines one interface (`ScdcClient`) and
will implement one (`LinkTrainer`) that the integration layer, once built, will define;
the dotted edge below marks that planned relationship.

```mermaid
flowchart LR
    dt["display-types"]
    hal["hdmi-hal"]
    culvert["culvert"]
    plumbob["plumbob"]
    integration["integration layer (planned)"]

    dt --> plumbob
    hal --> plumbob
    culvert -->|"implements ScdcClient"| plumbob
    plumbob -.->|"will implement LinkTrainer"| integration
```

`plumbob` does not depend on `culvert`. The relationship runs the other way: `culvert`
implements `plumbob::ScdcClient` for `Scdc<T>`, gated behind a `plumbob` cargo feature.
Any crate that implements `ScdcClient` is substitutable.

## Out of scope

- **Rate fallback policy** — the caller decides which rates to pass to `train` and what
  to do with a `FallbackRequired`; plumbob only steps down when the sink asks it to.
- **SCDC register decoding** — plumbob reads typed values from `ScdcClient` and does not
  decode raw register bytes or know SCDC register addresses.
- **PHY vendor sequences** — plumbob calls `HdmiPhy` methods (`set_frl_rate`, `send_ltp`,
  `set_frl_output`, `adjust_equalization`, which carries each lane's TxFFE level and the
  tester's `TxFfeRestrictions`); the register sequences behind them live in
  platform PHY backends.
- **Timing** — plumbob is poll-based and has no clock. The inter-poll delay is the
  `ScdcClient` implementation's; the poll limits in `TrainingConfig` are plumbob's only
  timeout mechanism. A wall-clock deadline is the caller's: its `ScdcClient` fails one
  polled read once the deadline has passed, but none after LTS:L has begun (see "How long
  one `train` call can take" in [`doc/architecture.md`]doc/architecture.md).
- **TMDS link setup** — plumbob handles FRL training only.

## Documentation

- [`doc/architecture.md`]doc/architecture.md — role, scope, the training procedure,
  interface boundaries, design principles, and the async roadmap
- [`doc/model.md`]doc/model.md — the protocol, configuration and outcome types
- [`doc/testing.md`]doc/testing.md — how the state machine is tested without hardware

## Verifying releases

Releases are built on GitHub Actions and attested with
[SLSA Build Level 2](https://slsa.dev) provenance (from 0.1.3). To verify a release
`.crate` against its signed provenance, install the
[GitHub CLI](https://cli.github.com/) and run:

```sh
gh attestation verify plumbob-X.Y.Z.crate --repo DracoWhitefire/plumbob
```

The attested `.crate` is attached to each
[GitHub release](https://github.com/DracoWhitefire/plumbob/releases).

Releases from 0.2.0 are uploaded through crates.io
[trusted publishing](https://rust-lang.github.io/rfcs/3691-trusted-publishing-cratesio.html): the publish
workflow obtains a short-lived token via OpenID Connect instead of using a stored
API token. crates.io can also reject uploads made with an API token for this crate;
the badge above shows whether that setting is on. Each version's publishing details,
including the workflow run and commit, are available from the crates.io API:

```sh
curl -A "your-tool (contact)" https://crates.io/api/v1/crates/plumbob/X.Y.Z
# see .version.trustpub_data
```

Versions up to 0.1.3 were uploaded with an API token (0.1.0 by hand, 0.1.1 to 0.1.3
from CI). 0.1.0 to 0.1.2 carry no provenance and have no GitHub release; 0.1.3 carries
provenance but was also uploaded with an API token.

## License

Licensed under the [Mozilla Public License 2.0](LICENSE).