plumbob-async 0.2.0

Async FRL link training state machine for HDMI 2.1
Documentation

plumbob-async

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

Async FRL link training state machine for HDMI 2.1.

plumbob-async is the async companion to plumbob. It runs plumbob's link training state machine — plumbob::lts::run, which performs no I/O itself — over an async ScdcClient and an async HdmiPhy, .awaiting each operation, so training yields to the executor wherever the I/O waits. There is one state machine for sync and async training: the outcomes, errors and traces are exactly plumbob's. All shared protocol and training types are re-exported from plumbob.

Usage

cargo add plumbob-async

Implement ScdcClient against your async SCDC transport, one method per register operation (the async counterpart of plumbob::ScdcClient; see its documentation for what each method must guarantee):

use plumbob_async::{
    CedCounters, ClearableUpdateFlags, FrlConfig, LtpRequests, ScdcClient, SourceTestConfig,
    UpdateFlags,
};

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

impl ScdcClient for MyScdcClient {
    type Error = MyError;

    // Status_Flags_0 bit 6. Polled in LTS:2: await the poll interval (2 ms by default) here.
    async fn read_flt_ready(&mut self) -> Result<bool, MyError> { todo!() }
    // Update_0, all seven flags. Polled in LTS:3 and LTS:P: await the poll interval here too.
    async fn read_update_flags(&mut self) -> Result<UpdateFlags, MyError> { todo!() }
    async fn clear_update_flags(&mut self, flags: ClearableUpdateFlags) -> Result<(), MyError> { todo!() }
    async fn read_ltp_requests(&mut self) -> Result<LtpRequests, MyError> { todo!() }
    async fn read_source_test_config(&mut self) -> Result<SourceTestConfig, MyError> { todo!() }
    async fn write_config_0_defaults(&mut self) -> Result<(), MyError> { todo!() }
    async fn write_frl_config(&mut self, config: FrlConfig) -> Result<(), MyError> { todo!() }
    async fn read_ced(&mut self) -> Result<CedCounters, MyError> { todo!() }
}

Construct an FrlTrainer and train over the rates to try, highest first; the sink's requests for a lower rate step down through the list:

use plumbob_async::{FrlTrainer, HdmiForumFrl, 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).await?;
match trained.outcome {
    TrainingOutcome::Success { achieved_rate, .. } => println!("Trained at {achieved_rate:?}"),
    TrainingOutcome::FallbackRequired { reason, .. } => println!("No FRL link: {reason:?}"),
    _ => {}
}
for warning in trained.iter_warnings() {
    println!("Warning: {warning:?}");
}

After Success, start video on the PHY the trainer holds — trainer.phy_mut().set_frl_output(FrlOutput::Active).await, with FrlOutput and the HdmiPhy trait imported from plumbob_async — or on the PHY into_parts returns. scdc, scdc_mut, phy and phy_mut give access to both in between.

The HdmiPhy bound on P is hdmi_hal_async::phy::HdmiPhy. The FrlConfig, TrainingConfig, Trained, TrainingOutcome, TrainingWarning, TrainingError, and all protocol types are re-exported from plumbob and are identical between the sync and async APIs: train returns the outcome with the warnings the attempt produced, such as a sink requesting an undefined pattern value.

Multi-threaded executors

The plain traits promise nothing about their futures, so code generic over ScdcClient and HdmiPhy cannot spawn training on a multi-threaded executor. For that, implement the Send twins, SendScdcClient and hdmi-hal-async's SendHdmiPhy (re-exported): the same methods, returning futures that are Send, with errors that are Send. With both, the futures of train, the other training methods and exit_to_tmds are Send in generic code (train_with_events given a Send callback). Bound the code by the twins alone, as below: adding + ScdcClient or + HdmiPhy, which the twins imply anyway, makes the compiler use that bound instead of the blanket implementation, and the futures are no longer known to be Send:

use plumbob_async::{FrlTrainer, HdmiForumFrl, SendHdmiPhy, SendScdcClient, TrainingConfig};

impl SendScdcClient for MyThreadSafeScdcClient {
    type Error = MyError;

    async fn read_flt_ready(&mut self) -> Result<bool, MyError> { todo!() }
    // … the other seven methods, as for `ScdcClient`
}

fn spawn_training<C, P>(mut trainer: FrlTrainer<C, P>, rates: &'static [HdmiForumFrl])
where
    C: SendScdcClient + 'static,
    P: SendHdmiPhy + 'static,
{
    tokio::spawn(async move {
        match trainer.train(rates, &TrainingConfig::default()).await {
            Ok(trained) => println!("{:?}", trained.outcome),
            Err(_) => println!("training failed"),
        }
    });
}

A twin implementation is also an implementation of the plain trait, so it works with code written against either. Import the trait your code is bounded by: with both in scope, method calls on a twin implementation are ambiguous. Code that keeps the types concrete needs no twins: its train future is Send when the client's and PHY's futures and error types are.

Training procedure

The procedure is plumbob's — LTS:2 (prepare), LTS:3 (train), LTS:P (pass), with LTS:4 (lower rate) on the sink's request and LTS:L (exit to TMDS) before every FallbackRequired and after an I/O error — because it is plumbob's code: FrlTrainer runs plumbob::lts::run over its async SCDC client and PHY. See plumbob's README and architecture document for the states, the poll and retrain limits, and the per-lane rules.

FallbackRequired { reason } means no listed rate trained; 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.

trainer.exit_to_tmds().await runs LTS:L on its own, to take a trained link down when the display is disabled or unplugged, or before a mode change.

Cancellation

train is not cancel-safe. Dropping its future — losing a select! against a hot-unplug event, or hitting a timeout — stops training wherever it is, and a dropped future cannot run LTS:L: the sink may be left configured for an FRL rate and the PHY driving training patterns. After dropping one, return both ends to TMDS yourself:

select! {
    result = trainer.train(&rates, &config) => handle(result),
    _ = hot_unplug.wait() => {
        trainer.exit_to_tmds().await?;
    }
}

To bound how long training takes, prefer the poll limits in TrainingConfig: every attempt that hits one ends in a defined state — with LTS:L already run, or, while the sink sets FLT_no_timeout (compliance testing), with the link held for the test equipment (TrainingOutcome::NoTimeoutHold).

For a wall-clock deadline, or to stop on a signal without dropping the future, fail one polled ScdcClient read with an error of your own and let every later call through: training stops, LTS:L returns both ends to TMDS, and train returns TrainingError::Scdc with your error. Never fail a read made after LTS:L has begun (after a write_frl_config with HdmiForumFrl::NotSupported), until a write_frl_config with an FRL rate starts training again. LTS:L is skipped with TrainingConfig::exit_to_tmds_on_error off, and the link is held while the sink sets FLT_no_timeout; plumbob::ScdcClient's "Stopping early" has the details.

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 and its TrainingEvents are plumbob's: a sync trace and an async trace of the same sink are identical.

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

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

Without an allocator, train_with_events(&rates, &config, &mut |event| …).await delivers the same events as they occur, to a plain callback.

Features

Feature Default Description
std no Implies alloc; no additional API surface
alloc no Enables TrainingTrace, train_traced and train_at_rate_traced

No features are enabled by default.

no_std builds

plumbob-async declares #![no_std] throughout. The bare crate provides full async training without an allocator. The alloc feature adds TrainingTrace, train_traced and train_at_rate_traced; std implies alloc.

# no_std, no heap
cargo add plumbob-async

# no_std + trace support
cargo add plumbob-async --features alloc

# std targets
cargo add plumbob-async --features std

No executor is assumed. The traits impose no constraint on which async runtime is used — Embassy, Tokio, and async-std are all valid implementors.

Stack position

flowchart LR
    dt["display-types"]
    hal["hdmi-hal"]
    hal_async["hdmi-hal-async"]
    plumbob["plumbob"]
    plumbob_async["plumbob-async"]

    dt --> hal --> hal_async --> plumbob_async
    dt --> plumbob --> plumbob_async

plumbob-async depends on plumbob for the state machine and all shared data types, and on hdmi-hal-async for the async HdmiPhy trait. It does not depend on any executor or runtime crate.

Out of scope

  • The training procedure — it is plumbob's (plumbob::lts::run); this crate supplies async I/O to it.
  • Rate fallback policy — the caller decides which rates to pass to train and what to do with a FallbackRequired.
  • SCDC register decoding — plumbob-async reads typed values from ScdcClient and does not decode raw register bytes.
  • PHY vendor sequences — plumbob-async calls HdmiPhy methods; the register sequences live in platform PHY backends.
  • Timing and inter-poll delay — the poll limits in TrainingConfig are the only timeout mechanism, and a wall-clock deadline is the caller's (see "Cancellation"); the interval belongs inside the polled ScdcClient methods, and awaiting it is what lets other tasks run: an ScdcClient whose futures are always ready keeps the executor for the whole train call (up to 7000 polls with the defaults; see plumbob's architecture document for the bound).
  • Executor dependency — no runtime crate is in the dependency graph. The plain traits' futures are not required to be Send; for a multi-threaded executor, implement the Send twins (see "Multi-threaded executors").

Documentation

Verifying releases

Releases are built on GitHub Actions and attested with SLSA Build Level 2 provenance (from 0.2.0). To verify a release .crate against its signed provenance, install the GitHub CLI and run:

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

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-async/X.Y.Z
# see .version.trustpub_data

0.1.0 was uploaded by hand with an API token; it carries no provenance and has no GitHub release.

License

Licensed under the Mozilla Public License 2.0.