plumbob 0.2.0

FRL link training state machine for HDMI 2.1
Documentation

plumbob

CI crates.io docs.rs License: MPL-2.0 Rust 1.85+ SLSA Level 2 trusted publishing only

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

cargo add plumbob

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

use plumbob::{
    CedCounters, 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 Source_Test_Update, FRL_start, FLT_update. 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.
    fn clear_update_flags(&mut self, flags: UpdateFlags) -> 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: FLT_no_timeout.
    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:

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 TrainingWarnings 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. As elsewhere in the stack, they are a Vec with the alloc feature and a fixed array (MAX_WARNINGS) without it; iter_warnings reads either.

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.

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.

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 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:

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:

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 { flt_no_timeout: 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 { flt_no_timeout: 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 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:

cargo add plumbob --features alloc

std — implies alloc:

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.

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); 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 the only timeout mechanism.
  • TMDS link setup — plumbob handles FRL training only.

Documentation

  • doc/architecture.md — role, scope, the training procedure, interface boundaries, design principles, and the async roadmap
  • doc/model.md — the protocol, configuration and outcome types
  • 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 provenance (from 0.1.3). To verify a release .crate against its signed provenance, install the GitHub CLI and run:

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

The attested .crate is attached to each GitHub release.

Releases from 0.2.0 are uploaded through crates.io trusted publishing: 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:

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.