plumbob
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
Implement ScdcClient against your SCDC transport, one method per register operation:
use ;
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 ;
let mut trainer = new;
let config = default;
let rates = ;
let trained = trainer.train?;
match trained.outcome
for warning in trained.iter_warnings
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 = trainer.train_traced;
println!;
for event in &trace.events
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?;
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:
std — implies alloc:
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
trainand what to do with aFallbackRequired; plumbob only steps down when the sink asks it to. - SCDC register decoding — plumbob reads typed values from
ScdcClientand does not decode raw register bytes or know SCDC register addresses. - PHY vendor sequences — plumbob calls
HdmiPhymethods (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
ScdcClientimplementation's; the poll limits inTrainingConfigare 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 roadmapdoc/model.md— the protocol, configuration and outcome typesdoc/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:
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:
# 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.