From 979dc7a772e154073a9f12d158b3c07bfb1c31f5 Mon Sep 17 00:00:00 2001 From: flamingcow Date: Thu, 13 Aug 2026 07:10:39 -0700 Subject: [PATCH] BCM module diagnostics replace timestamp probes: bringup forces EEE off and runs ECD (re-run on every reset, re-baselining past its blip), 1 Hz SNR margin + corrected counters on panel and console; X520 bench divergences bypassed and tracked in open-questions --- counters.go | 65 ++- docs/goals.md | 4 +- docs/modules/fs/README.md | 4 +- docs/open-questions.md | 10 + docs/state.md | 12 +- init.go | 2 + main.go | 127 +++--- main_test.go | 1 - noise.go | 2 +- phy.go | 808 ++++++++++++++++++++++++++++++++++++++ phy_test.go | 103 +++++ probe.go | 226 ----------- probe_test.go | 25 -- render.go | 33 ++ system.go | 38 +- ts.go | 31 +- ui.go | 130 ++++-- ui_test.go | 35 +- 18 files changed, 1240 insertions(+), 416 deletions(-) create mode 100644 phy.go create mode 100644 phy_test.go delete mode 100644 probe.go delete mode 100644 probe_test.go diff --git a/counters.go b/counters.go index c34305f..d2d56df 100644 --- a/counters.go +++ b/counters.go @@ -1,7 +1,9 @@ package main import ( + "fmt" "os" + "path/filepath" "strconv" "strings" "sync/atomic" @@ -28,22 +30,43 @@ var nicTxFields = []string{ // Receiving is the hardware's, and is taken from the driver's own array, which // sysfs both flattens and overlaps: ice folds crc errors and jabbers into // rx_errors, so the old sum of rx_errors alongside rx_crc_errors charged every -// bad frame check twice. Named individually here, so nothing contains anything -// else in the list. -var nicRxStats = []string{ - "rx_crc_errors.nic", - "rx_jabber.nic", - "rx_undersize.nic", - "rx_oversize.nic", - "rx_fragments.nic", - "rx_dropped.nic", - // Below the frame: a 64b/66b block that decoded to no legal symbol, and the - // fault ordered sets the pcs sends when it loses sync. A cable going - // marginal moves these while every frame still arrives intact, which is as - // close to a bit error rate as this link will report. - "illegal_bytes.nic", - "mac_local_faults.nic", - "mac_remote_faults.nic", +// bad frame check twice. Named individually per driver, so nothing contains +// anything else in its list. +var nicRxStatsByDriver = map[string][]string{ + // The reference set (E810). Below the frame: illegal_bytes is a 64b/66b + // block that decoded to no legal symbol, and the faults are the ordered + // sets the pcs sends when it loses sync. A cable going marginal moves + // these while every frame still arrives intact, which is as close to a + // bit error rate as this link will report. + "ice": { + "rx_crc_errors.nic", + "rx_jabber.nic", + "rx_undersize.nic", + "rx_oversize.nic", + "rx_fragments.nic", + "rx_dropped.nic", + "illegal_bytes.nic", + "mac_local_faults.nic", + "mac_remote_faults.nic", + }, + // The 82599 exposes no jabber, fragment, illegal-byte or fault counters + // through ethtool — this is the closest bench set, and one of the X520 + // divergences listed in docs/open-questions.md. + "ixgbe": { + "rx_crc_errors", + "rx_missed_errors", + "rx_length_errors", + "rx_long_length_errors", + "rx_short_length_errors", + }, +} + +func ifDriver(name string) (string, error) { + link, err := os.Readlink("/sys/class/net/" + name + "/device/driver") + if err != nil { + return "", err + } + return filepath.Base(link), nil } func readUint(path string) (uint64, bool) { @@ -93,7 +116,15 @@ type nicPoller struct { } func newNICPoller(fd int, txName, rxName string, total *atomic.Uint64) (*nicPoller, error) { - rx, err := newStatReader(fd, rxName, nicRxStats) + drv, err := ifDriver(rxName) + if err != nil { + return nil, err + } + want, ok := nicRxStatsByDriver[drv] + if !ok { + return nil, fmt.Errorf("%s: no rx error statistic set for driver %s", rxName, drv) + } + rx, err := newStatReader(fd, rxName, want) if err != nil { return nil, err } diff --git a/docs/goals.md b/docs/goals.md index 1e40ba5..8c5970a 100644 --- a/docs/goals.md +++ b/docs/goals.md @@ -8,8 +8,8 @@ The target of measurement is the cable, not throughput. Line rate is a means to 1. **Loss and error attribution** — reception gaps, link errors, NIC/driver counters as first-class output alongside application loss. Baseline loss must be exactly zero before a run counts; any host-side loss masks real cable faults. 2. **Noise tolerance** — a deliberately-bad "noise" cable intertwined with the test cable, driven by link up/down cycling, stresses the cable under test with alien crosstalk. -3. **Per-pair SNR** from the module PHYs — the leading indicator of a marginal cable before it drops frames. IEEE 802.3an standard registers on the Marvells; the BCM leaves those unpopulated and reports through its vendor command handler instead (modules/fs/). -4. **Cable length** — sanity check and fault localization. The BCM ECD is the product path: per-pair lengths in meters, healthy pairs included, proven meter-accurate on the bench (modules/fs/). +3. **Per-pair SNR** from the module PHYs — the leading indicator of a marginal cable before it drops frames. IEEE 802.3an standard registers on the Marvells; the BCM leaves those unpopulated and reports through its vendor command handler instead (modules/fs/). Shown as margin over the operating point, classified good/marginal/bad, so nobody needs the magic numbers to read it (`phy.go`). +4. **Cable length** — sanity check and fault localization. The BCM ECD is the product path: per-pair lengths in meters, healthy pairs included, proven meter-accurate on the bench (modules/fs/); run at bringup and on every reset, with pair-swap and fault verdicts on the panel. 5. **Pre-FEC error visibility** — corrected-error counters that move before post-FEC loss appears. Vendor-specific; located on the Aquantia (modules/fibergaga/), unlocated elsewhere. Standard latched PCS counters (errored blocks, BER, block-lock loss) are the working proxy under noise stress. ## Design preferences that shaped the tool diff --git a/docs/modules/fs/README.md b/docs/modules/fs/README.md index d8d680e..45819d9 100644 --- a/docs/modules/fs/README.md +++ b/docs/modules/fs/README.md @@ -27,7 +27,7 @@ Protocol and full verified catalog: [bcm84891l-mdio-commands.md](bcm84891l-mdio- | Capability | How | Observed | |---|---|---| -| Per-pair SNR | `CMD_GET_SNR` 0x8030, **invoked bare** (writing the documented DATA1 display flag returns zeros; IEEE 1.133–1.140 never populate — constant 0x8080) | DATA2–5 = SNR A–D ×0.1 dB; ≈ 27–32 dB absolute on bench, 0.1–0.4 dB jitter. 10GBASE-T operating point ≈ 26.5 dB, so margin ≈ value − 26.5 | +| Per-pair SNR | `CMD_GET_SNR` 0x8030, **invoked bare** (writing the documented DATA1 display flag returns zeros; IEEE 1.133–1.140 never populate — constant 0x8080) | DATA2–5 = SNR A–D ×0.1 dB; ≈ 27–32 dB absolute on bench, 0.1–0.4 dB jitter. 10GBASE-T operating point ≈ 26.5 dB, so margin ≈ value − 26.5. cabletest shows the margin classified green ≥ 3 dB / amber ≥ 1 dB / red below — provisional thresholds until the graded-noise correlation run | | Die temperature | `CMD_GET_CURRENT_TEMP` 0x8031 | ~68–70 °C on bench | | Supply rails | `GET_CURRENT_VOLTAGE` 0x802F | 0.8 V and 1.88 V rails, tenths of mV | | Error counters | IEEE PCS 3.32/3.33 — block lock, latched errored-block/BER, clear-on-read | The noise-stress error proxy | @@ -37,7 +37,7 @@ Protocol and full verified catalog: [bcm84891l-mdio-commands.md](bcm84891l-mdio- | Item | Command | State | |---|---|---| -| EEE / AutogrEEEn | 0x8008/0x8009 | Once read 0x0047 (10G AutogrEEEn variable latency + 5G/1G native — local-only, invisible in IEEE 7.60/7.61, which read 0); later reads 0. **Forced all-off** via SET with explicit params `(0, 0, 0x7A12, 0x480, 0)` + AN restart, verified. cabletest should apply this defensively at bringup | +| EEE / AutogrEEEn | 0x8008/0x8009 | Once read 0x0047 (10G AutogrEEEn variable latency + 5G/1G native — local-only, invisible in IEEE 7.60/7.61, which read 0); later reads 0. **Forced all-off** via SET with explicit params `(0, 0, 0x7A12, 0x480, 0)` + AN restart, verified. cabletest applies this defensively at every bringup and verifies 7.60 reads 0 after relink (`phy.go`) | | EEE wire-truth | arm 0x801A after link-up, read 0x801B | Zero LPI events/duration on idle link; repeat under traffic | | Fast retrain | 0x800A (datasheet titles it EMI_MODE; description is fast retrain) | Enabled 10G/5G/2.5G; IEEE 1.147 = 0x0019, count bits zero. Keep enabled; read the 1.147 count per run — a marginal cable that fast-retrains still gets counted | | Pair map | 0x8000 | DATA2 = 0x00E4 = identity (A/B/C/D straight through) — MDI wiring verification works | diff --git a/docs/open-questions.md b/docs/open-questions.md index a4871e2..4e0dbf7 100644 --- a/docs/open-questions.md +++ b/docs/open-questions.md @@ -21,3 +21,13 @@ The register question is answered (post-FEC vs corrected-by-iteration histogram ## 4. Wiitek VCT — pursue or leave dead? No confirmed-safe path exists (every candidate lands in the µC danger window). The open decision is whether the capability is worth the NDA route or a sacrificial unit — the product doesn't need it for length ([modules/wiitek/](modules/wiitek/README.md), [modules/README.md](modules/README.md)). + +## 5. X520 bench divergences — features to restore on the product NIC + +Running on the X520 (BCM development) required parking product-NIC capabilities the 82599 lacks. Each stays parked only until the ConnectX-5 is in; none is a settled design change: + +- **Hardware timestamp hard check bypassed** (`ts.go`): `rx_filter=ALL` failure now reports yellow and continues instead of stopping the run. On the X520 that means the per-frame-stamp rate buckets never fill and the panel/console rates read zero — the measurement doctrine (exact per-frame RX stamps as a hard host requirement) is intact in the docs and must return to fatal on the product NIC. +- **RX error counter set is per-driver** (`counters.go`, `nicRxStatsByDriver`): the ice set is the reference — jabber, fragments, `illegal_bytes` (64b/66b decode errors) and MAC local/remote faults have **no ixgbe ethtool equivalent**, so those signals are simply absent on the bench. The mlx5 name set needs deriving on CX-5 arrival; the "as close to BER as the link reports" counters (illegal bytes, faults) are the ones to insist on finding there. +- **TX interrupt moderation** (`system.go`): ixgbe's mixed rx/tx vectors reject a tx-specific value, so `checkCoalesce` falls back to rx-shared-with-tx on EINVAL. Generic and self-reporting, but verify the product NIC takes the full rx+tx pair (the fallback must never fire there). +- **`testDriver` still names "ice"** (`main.go`): the default pair discovery has no working target — bench runs pass `-a`/`-b` explicitly. Point it at the product driver (mlx5_core) when the CX-5 lands. +- **Module I2C transport is ixgbe-only** (`phy.go`, `openBCM`): the sff_i2c debugfs path. The CX-5 needs the MCIA answer (question 1) and a second transport arm. diff --git a/docs/state.md b/docs/state.md index 2cdc7dc..b7eaa39 100644 --- a/docs/state.md +++ b/docs/state.md @@ -2,7 +2,7 @@ ## Committed tree -AF_PACKET raw sockets everywhere (`sock.go`); flow-director steering; per-packet-MAC-rx-stamped rate buckets (`SO_TIMESTAMPING` cmsg, `rx_filter=ALL` as a hard host check — nics/README.md for what that demands of the NIC); read-time-stamped NIC-counter rates; hardware-timestamped length probes (`probe.go`); framebuffer UI; harness. +AF_PACKET raw sockets everywhere (`sock.go`); flow-director steering; per-packet-MAC-rx-stamped rate buckets (`SO_TIMESTAMPING` cmsg, `rx_filter=ALL` as a hard host check — nics/README.md for what that demands of the NIC; **temporarily bypassed** in `ts.go` so BCM work can run on the X520, which cannot stamp — the check reports yellow and the panel rates read zero there; restore to fatal for the product NIC); read-time-stamped NIC-counter rates; BCM module diagnostics (`phy.go`, over the patched-ixgbe `sff_i2c` debugfs, compound-op framing): bringup identifies both modules, forces EEE off with an AN restart, then runs the ECD — per-pair verdicts, lengths and pair maps are the length/wiring path — and every reset re-runs it, re-baselining the counters only after the diag's own link blip so it is never charged to the run; a 1 Hz poller feeds per-pair SNR margin (vs the ≈26.5 dB operating point; green ≥ 3 dB, amber ≥ 1 dB — provisional until the graded-noise run) and the corrected-error set (PCS 3.33 errored blocks/BER, PMA 1.147 fast-retrain count) to the panel and console; framebuffer UI; harness. ## Stashes @@ -17,7 +17,7 @@ AF_PACKET raw sockets everywhere (`sock.go`); flow-director steering; per-packet | Interface | Device | Role (rules: hardware.md) | |---|---|---| -| `enp1s0f0` | X520 port 0 (ixgbe) | Test pair — new Wiitek module | +| `enp1s0f0` | X520 port 0 (ixgbe) | Test pair — FS module | | `enp1s0f1` | X520 port 1 (ixgbe) | Test pair — FS module | | `enp3s0f0np0` / `enp3s0f1np1` | X710 (i40e) | Noise pair (has been `enp4s0f*` across reboots) | | `enp88s0` | igc | LAN uplink, default route; sibling `enp89s0` is dark | @@ -26,10 +26,10 @@ AF_PACKET raw sockets everywhere (`sock.go`); flow-director steering; per-packet | Item | Status | Notes | |---|---|---| -| X520-DA2 | **Installed** in the single PCIe slot (E810 out); PCIe 5 GT/s ×8 | Port 0 `enp1s0f0` = new Wiitek (SN WAMZ012606X039U); port 1 `enp1s0f1` = FS (SN S2433774168); cable linked at 10G. Stock ixgbe needs `allow_unsupported_sfp=1` — the *FS* trips qualification (hardware.md) | +| X520-DA2 | **Installed** in the single PCIe slot (E810 out); PCIe 5 GT/s ×8 | Both ports FS (port 1 SN S2433774168); cable linked at 10G. Stock ixgbe needs `allow_unsupported_sfp=1` — the *FS* trips qualification (hardware.md) | | ConnectX-5 | **Ordered** (dual SFP28, PCIe x8) — the product NIC candidate | mlx5 is the one driver meeting the full requirement set: stamps every packet, shared PHC across ports, native ETHER_FLOW steering. Open: MCIA diagnostics questions (nics/connectx-5/). Arrival notes: ports may ship in InfiniBand mode (`mlxconfig set LINK_TYPE_P1=2 LINK_TYPE_P2=2`); SFP+ drops into SFP28 cages at 10G; check `mlx5_ib` vs channel changes (the irdma lesson) | -| Replacement Wiiteks | Arrived; one in X520 port 0 | Originals bricked by register exploration — modules/wiitek/ trap first | -| FS SFP-10G-T-100 ×2 | In hand; one in X520 port 1 | BCM84891L, documented, robust | +| Replacement Wiiteks | Arrived; on the shelf | Originals bricked by register exploration — modules/wiitek/ trap first | +| FS SFP-10G-T-100 ×2 | Both in the X520 test pair | BCM84891L, documented, robust. **2× FS at both ends is the expected product module config** unless mixed ends prove wanted | | Fibergaga SFP-10G-T-30M | In hand | Aquantia, RollBall, the documented oracle | | 10Gtek | In hand | Claims SFP-10G-SR, still copper RJ45; filler, not in the test set | | E810 | Out of the box | Patched ice + `sff_i2c` remains useful only if it returns for read-side work | @@ -64,5 +64,5 @@ In `~/work/` alongside the phydiag artifacts, ready to fold into the repo's `ker ## Open items -- **BCM ECD works** (recipe recovered from the OpenBCM SDK, validated on the FS — modules/fs/): per-pair lengths meter-accurate against a known ~45 m cable. Remaining work is characterizing the link blip the run causes (length is a between-runs operation until then). The FS ECD-chapter ask is now confirmation, not unblocking. +- **BCM ECD works** (recipe recovered from the OpenBCM SDK, validated on the FS — modules/fs/): per-pair lengths meter-accurate against a known ~45 m cable. cabletest runs it at bringup and on every reset, re-baselining counters after the relink so the blip is never charged (`phy.go`). Remaining work is characterizing the link blip the run causes (length stays a between-measurements operation until then). The FS ECD-chapter ask is now confirmation, not unblocking. - **Pre-FEC verification** on the Aquantia — counters documented; needs the graded-noise correlation run (design: modules/fibergaga/). diff --git a/init.go b/init.go index f006dbe..b75fb5d 100644 --- a/init.go +++ b/init.go @@ -22,6 +22,8 @@ type mountSpec struct { var wantMounts = []mountSpec{ {"/proc", "proc", unix.PROC_SUPER_MAGIC, unix.MS_NOSUID | unix.MS_NODEV | unix.MS_NOEXEC}, {"/sys", "sysfs", unix.SYSFS_MAGIC, unix.MS_NOSUID | unix.MS_NODEV | unix.MS_NOEXEC}, + // The module I2C transport lives behind debugfs. + {"/sys/kernel/debug", "debugfs", unix.DEBUGFS_MAGIC, unix.MS_NOSUID | unix.MS_NODEV | unix.MS_NOEXEC}, // devtmpfs reports itself as tmpfs, which is why the magic alone is not // enough to tell it apart from the directory it would be mounted over. {"/dev", "devtmpfs", unix.TMPFS_MAGIC, unix.MS_NOSUID}, diff --git a/main.go b/main.go index 2a29d97..3cdaaac 100644 --- a/main.go +++ b/main.go @@ -37,12 +37,7 @@ type direction struct { streams []lossWindow txFDs []int rxFDs []int - - probeSpec *frameSpec - probeTxFD int - probeRxFD int - statFD int - cable *cableStats + statFD int // Guards everything the sampler touches. The counters are read on their own // clock and drawn on another, and the two must not read them at once: @@ -220,16 +215,17 @@ func (d *direction) reset() { d.base = d.capture() d.win.push(d.base) d.mu.Unlock() - - d.cable.reset() } // Returns the new start time, so the uptime shown alongside the totals counts // from the reset rather than from launch. -func resetAll(dirs []*direction, stats *streamTable) time.Time { +func resetAll(dirs []*direction, mods []*phyModule, stats *streamTable) time.Time { for _, d := range dirs { d.reset() } + for _, m := range mods { + m.reset() + } stats.sinceHeader = 0 fmt.Println(stats.rule("counters reset")) return time.Now() @@ -248,15 +244,18 @@ func gbps(bytes, frames uint64, secs float64) float64 { var intervalCols = []colSpec{ {group: "NOW", title: "bits/s", width: 9, right: true}, {group: "NOW", title: "packets/s", width: 9, right: true}, + {group: "NOW", title: "snr", width: 6, right: true}, {group: "NOW", title: "lost", width: 7, right: true}, {group: "NOW", title: "corrupt", width: 7, right: true}, {group: "NOW", title: "link", width: 7, right: true}, {group: "NOW", title: "internal", width: 8, right: true}, + {group: "NOW", title: "corrected", width: 9, right: true}, {group: "NOW", title: "noise", width: 7, right: true}, {group: "OVERALL", title: "elapsed", width: 9, right: true}, {group: "OVERALL", title: "packets", width: 9, right: true}, {group: "OVERALL", title: "bytes", width: 9, right: true}, {group: "OVERALL", title: "metres", width: 6, right: true}, + {group: "OVERALL", title: "corrected", width: 9, right: true}, {group: "OVERALL", title: "lost", width: 9, right: true}, {group: "OVERALL", title: "corrupt", width: 9, right: true}, {group: "OVERALL", title: "link", width: 9, right: true}, @@ -271,7 +270,6 @@ type view struct { rxFrames, rxBytes uint64 since errs window errs - cable cableView } func errsBetween(b, n counterSet) errs { @@ -297,7 +295,6 @@ func (d *direction) counters(now counterSet) view { return view{ rxFrames: now.s.rxFrames - d.base.s.rxFrames, rxBytes: now.s.rxBytes - d.base.s.rxBytes, - cable: d.cable.view(), since: errsBetween(d.base, now), } } @@ -329,7 +326,7 @@ func (d *direction) displayView() view { n := d.win.count() if n == 0 { d.mu.Unlock() - return view{cable: d.cable.view()} + return view{} } v := d.counters(d.win.at(n - 1)) if n >= 2 { @@ -344,19 +341,22 @@ func (d *direction) displayView() view { // The same figures the panel draws, in the same order: the last second as // rates and error flags with the noise cable riding at the end of them, then // everything since the reset. -func totalRow(elapsed time.Duration, v view, target float64, length string, noiseMissing uint64) []string { +func totalRow(elapsed time.Duration, v view, target float64, phy phyDisplay, noiseMissing uint64) []string { return []string{ rateCell(v.rxGbps*1e9, target*1e9), scaleSI(v.rxPPS), + snrCell(phy), flagCell(v.window.lost), flagCell(v.window.corrupt), flagCell(v.window.link), flagCell(v.window.internal), + correctedFlag(phy.recent), flagCell(noiseMissing), scaleTime(elapsed), scaleCount(v.rxFrames), scaleCount(v.rxBytes), - length, + phy.metres, + correctedCell(phy.corrected), statusCell(v.since.lost), statusCell(v.since.corrupt), statusCell(v.since.link), @@ -382,7 +382,6 @@ func buildDirection(label string, tx, rx endpoint) (*direction, error) { d := &direction{ txStats: txs, streams: newLossWindows(txs), - cable: newCableStats(), } // Held open for the life of the run: the stats ioctl is issued five times a // second and reopening a socket for each one is pure overhead. @@ -412,8 +411,8 @@ func buildDirection(label string, tx, rx endpoint) (*direction, error) { if err != nil { return nil, fmt.Errorf("%s rx socket for 0x%04x: %w", label, et, err) } - // The mac already stamps every frame for the probe's sake, so this only - // asks for the stamp to be delivered. + // The mac already stamps every frame; this only asks for the stamp to be + // delivered. if err := enableRxTimestamps(fd); err != nil { return nil, fmt.Errorf("%s rx timestamps for 0x%04x: %w", label, et, err) } @@ -421,26 +420,6 @@ func buildDirection(label string, tx, rx endpoint) (*direction, error) { d.rxStats = append(d.rxStats, &rxStats{}) } - // Deliberately given no flow rule: a few frames a second does not need a - // queue of its own, and the stamps are taken at the wire either way. - d.probeSpec = newFrameSpec(rx.mac, tx.mac, probeEther, []int{probeSize}) - fd, err := openTxSocket(tx.idx) - if err != nil { - return nil, fmt.Errorf("%s probe tx socket: %w", label, err) - } - if err := enableTxTimestamps(fd); err != nil { - return nil, fmt.Errorf("%s probe tx timestamps: %w", label, err) - } - d.probeTxFD = fd - fd, err = openRxSocket(rx.idx, probeEther) - if err != nil { - return nil, fmt.Errorf("%s probe rx socket: %w", label, err) - } - if err := enableRxTimestamps(fd); err != nil { - return nil, fmt.Errorf("%s probe rx timestamps: %w", label, err) - } - d.probeRxFD = fd - return d, nil } @@ -479,22 +458,6 @@ func (d *direction) start(wg *sync.WaitGroup, done *atomic.Bool, rxReady *sync.W }() } - sender := &probeSender{fd: d.probeTxFD, spec: d.probeSpec, stats: d.cable} - wg.Add(1) - go func() { - defer wg.Done() - defer holdPanic() - sender.run(done, startTx) - }() - - receiver := &probeReceiver{fd: d.probeRxFD, stats: d.cable, ready: rxReady} - wg.Add(1) - go func() { - defer wg.Done() - defer holdPanic() - receiver.run(done) - }() - wg.Add(1) go func() { defer wg.Done() @@ -510,8 +473,6 @@ func (d *direction) close() { for _, fd := range d.rxFDs { unix.Close(fd) } - unix.Close(d.probeTxFD) - unix.Close(d.probeRxFD) unix.Close(d.statFD) } @@ -519,8 +480,6 @@ const ( numStreams = 7 batchSize = 64 - probeEther uint16 = etherBase + numStreams - testDriver = "ice" // A constant rather than the negotiated speed, since this has to come up @@ -538,10 +497,9 @@ func main() { // which ethN shifts with every driver built into the kernel. aName := flag.String("a", "", "first interface (default: the ice pair)") bName := flag.String("b", "", "second interface") - nsPerM := flag.Float64("ns-per-m", 4.85, "mean of both directions, per metre of cable") flag.Parse() - if err := run(*aName, *bName, *nsPerM); err != nil { + if err := run(*aName, *bName); err != nil { fatal(err) } // A clean return is ctrl-alt-delete, which the kernel hands PID 1 as a @@ -584,7 +542,7 @@ func (s *sampler) run(done *atomic.Bool, startTx <-chan struct{}) { } } -func run(aName, bName string, nsPerM float64) (err error) { +func run(aName, bName string) (err error) { defer func() { if p := recover(); p != nil { if os.Getpid() != 1 { @@ -640,6 +598,12 @@ func run(aName, bName string, nsPerM float64) (err error) { return err } + modules, cable, moduleChecks := moduleBringup([2]string{a.name, b.name}) + if err := reportChecks("MODULES", moduleChecks); err != nil { + return err + } + diag := newCableDiag(modules, cable) + var dirs []*direction for _, p := range [][2]endpoint{{a, b}, {b, a}} { d, err := buildDirection(p[0].name+"->"+p[1].name, p[0], p[1]) @@ -673,7 +637,7 @@ func run(aName, bName string, nsPerM float64) (err error) { var rxReady sync.WaitGroup startTx := make(chan struct{}) for _, d := range dirs { - rxReady.Add(len(d.rxFDs) + 1) + rxReady.Add(len(d.rxFDs)) } for _, d := range dirs { d.start(&wg, &done, &rxReady, startTx) @@ -693,6 +657,16 @@ func run(aName, bName string, nsPerM float64) (err error) { defer holdPanic() noise.run(&done) }() + // Also ungated: SNR and temperature ride the module's own management bus, + // not the wire being measured. + for _, m := range modules { + wg.Add(1) + go func() { + defer wg.Done() + defer holdPanic() + m.run(&done) + }() + } // Every return from here on stops the workers before the deferred closes // pull their sockets out from under them: otherwise the sampler panics on a // closed fd and can mask the error that actually ended the run. An error @@ -754,41 +728,40 @@ func run(aName, bName string, nsPerM float64) (err error) { return fmt.Errorf("%v", p) case <-sig: return nil + // A reset re-measures the cable first — the cable under a reset is + // usually a new one — and the counters re-baseline when the diag's own + // link blip is over, so it is never charged to the fresh run. case <-space: - start = resetAll(dirs, stats) + if diag.kick(&done) { + fmt.Println(stats.rule("measuring cable")) + } + case <-diag.completed: + start = resetAll(dirs, modules, stats) case <-disp.fb.flips: now := time.Now() px, py, down := touch.get() x, y := disp.fb.fromPanel(px, py) if disp.holdReset(x, y, down, now) { - start = resetAll(dirs, stats) + diag.kick(&done) } disp.showVersion = down && disp.versionSpot.contains(x, y) for i, d := range dirs { views[i] = d.displayView() } - // Empty until the probe has a stamp from each direction, so the - // panel shows nothing there rather than a placeholder. - cable := "" - if m, ok := cableMetres(views, nsPerM); ok { - cable = fmt.Sprintf("%.1f", m) - } - if err := disp.render(totalView(views), now.Sub(start), cable, + info, measuring := diag.snapshot() + phy := phyDisplayFrom(info, measuring, modules[0].view(), modules[1].view()) + if err := disp.render(totalView(views), now.Sub(start), phy, noise.missing()); err != nil { return err } case now := <-tick.C: elapsed := now.Sub(start) - // Length needs both directions, so every row is sampled before any of - // them is printed. for i, d := range dirs { rows[i] = d.displayView() } - length := "-" - if m, ok := cableMetres(rows, nsPerM); ok { - length = fmt.Sprintf("%.1f", m) - } - for _, line := range stats.emit(totalRow(elapsed, totalView(rows), target, length, + info, measuring := diag.snapshot() + phy := phyDisplayFrom(info, measuring, modules[0].view(), modules[1].view()) + for _, line := range stats.emit(totalRow(elapsed, totalView(rows), target, phy, noise.missing())) { fmt.Println(line) } diff --git a/main_test.go b/main_test.go index 343cf5b..642dc11 100644 --- a/main_test.go +++ b/main_test.go @@ -169,7 +169,6 @@ func TestErrsBetweenBuckets(t *testing.T) { func TestResetDoesNotUnderflowTotals(t *testing.T) { d := &direction{ win: newRateWindow(8), - cable: newCableStats(), rxStats: []*rxStats{{}}, } diff --git a/noise.go b/noise.go index 624b48c..e266332 100644 --- a/noise.go +++ b/noise.go @@ -32,7 +32,7 @@ const ( noiseDownSpan = 5 * time.Second noiseFrameGap = 10 * time.Millisecond - noiseEther uint16 = probeEther + 1 + noiseEther uint16 = etherBase + numStreams ) // Kernel names shift with which drivers are built in, since ethN is handed out diff --git a/phy.go b/phy.go new file mode 100644 index 0000000..811e70b --- /dev/null +++ b/phy.go @@ -0,0 +1,808 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + + "golang.org/x/sys/unix" +) + +const ( + bcmI2CWrite = 0xAC + bcmI2CRead = 0xAD + + bcmMMDVendor uint16 = 0x1E + bcmRegCmd uint16 = 0x4005 + bcmRegStatus uint16 = 0x4037 + bcmRegData1 uint16 = 0x4038 + + bcmStInProgress uint16 = 0x0002 + bcmStPass uint16 = 0x0004 + bcmStError uint16 = 0x0008 + bcmStBusy uint16 = 0xBBBB + + bcmCmdGetPairSwap uint16 = 0x8000 + bcmCmdSetEEEMode uint16 = 0x8009 + bcmCmdGetSNR uint16 = 0x8030 + + bcmRegECDCtrl uint16 = 0x4006 + bcmRegECDResult uint16 = 0xA896 + bcmRegECDLen uint16 = 0xA897 + + bcmPHYIDHi = 0x3590 + bcmPHYIDLo = 0x5081 + + bcmReadDelayUs = 3000 + bcmRetryDelayUs = 10000 + + bcmStatusPoll = 100 * time.Millisecond + bcmStatusTries = 30 + + ecdPoll = 200 * time.Millisecond + ecdDeadline = 50 * time.Second + + pairIdentityMap = 0xE4 +) + +const ( + pairOK = 1 + pairOpen = 2 + pairShort = 3 + pairXtalk = 4 +) + +var pairVerdicts = map[int]string{ + pairOK: "ok", pairOpen: "OPEN", pairShort: "SHORT", pairXtalk: "XTALK", +} + +type bcm struct { + ifname string + path string + + // Serializes the multi-op sequences — a handler command, an ECD run — that + // would corrupt each other interleaved. Single register reads ride bare: + // the compound op makes each one atomic on the wire. + mu sync.Mutex +} + +func openBCM(ifname string) (*bcm, error) { + devLink, err := os.Readlink("/sys/class/net/" + ifname + "/device") + if err != nil { + return nil, fmt.Errorf("%s: %w", ifname, err) + } + drv, err := ifDriver(ifname) + if err != nil { + return nil, fmt.Errorf("%s: %w", ifname, err) + } + if drv != "ixgbe" { + return nil, fmt.Errorf("%s: no module I2C transport for driver %s", ifname, drv) + } + b := &bcm{ + ifname: ifname, + path: "/sys/kernel/debug/ixgbe/" + filepath.Base(devLink) + "/sff_i2c", + } + if _, err := os.Stat(b.path); err != nil { + return nil, fmt.Errorf("%s: %w (patched ixgbe?)", ifname, err) + } + return b, nil +} + +func (b *bcm) op(cmd string) (string, error) { + fd, err := unix.Open(b.path, unix.O_RDWR, 0) + if err != nil { + return "", fmt.Errorf("%s: %w", b.path, err) + } + defer unix.Close(fd) + if _, err := unix.Write(fd, []byte(cmd)); err != nil { + return "", fmt.Errorf("%s %q: %w", b.ifname, cmd, err) + } + buf := make([]byte, 256) + n, err := unix.Read(fd, buf) + if err != nil { + return "", fmt.Errorf("%s %q: %w", b.ifname, cmd, err) + } + resp := strings.TrimSpace(string(buf[:n])) + if !strings.HasPrefix(resp, "ok") { + return "", fmt.Errorf("%s %q: %s", b.ifname, cmd, resp) + } + return strings.TrimSpace(resp[2:]), nil +} + +func parseHexBytes(s string, n int) ([]byte, error) { + fields := strings.Fields(s) + if len(fields) != n { + return nil, fmt.Errorf("want %d bytes, got %q", n, s) + } + out := make([]byte, n) + for i, f := range fields { + var v byte + if _, err := fmt.Sscanf(f, "%x", &v); err != nil { + return nil, fmt.Errorf("byte %q in %q", f, s) + } + out[i] = v + } + return out, nil +} + +// One write-STOP-delay-read transaction under a single bus hold, so the +// driver's own SFP traffic can never consume the bridge's pending data. +func (b *bcm) compound(waddr, raddr byte, delayUs, n int, wdata []byte) ([]byte, error) { + var sb strings.Builder + fmt.Fprintf(&sb, "x %02x %02x %d %x", waddr, raddr, delayUs, n) + for _, v := range wdata { + fmt.Fprintf(&sb, " %02x", v) + } + resp, err := b.op(sb.String()) + if err != nil { + return nil, err + } + return parseHexBytes(resp, n) +} + +func (b *bcm) mdioReadDelay(devad, reg uint16, delayUs int) (uint16, error) { + d, err := b.compound(bcmI2CWrite, bcmI2CRead, delayUs, 2, + []byte{0x20 | byte(devad), byte(reg >> 8), byte(reg)}) + if err != nil { + return 0, err + } + return uint16(d[0])<<8 | uint16(d[1]), nil +} + +// 0x0000 is also the bridge's not-ready signature, so a zero is read again at +// a longer delay before being believed. +func (b *bcm) mdioRead(devad, reg uint16) (uint16, error) { + v, err := b.mdioReadDelay(devad, reg, bcmReadDelayUs) + if err != nil || v != 0 { + return v, err + } + return b.mdioReadDelay(devad, reg, bcmRetryDelayUs) +} + +func (b *bcm) mdioWrite(devad, reg, val uint16) error { + _, err := b.op(fmt.Sprintf("w %02x %02x %02x %02x %02x %02x", + bcmI2CWrite, byte(devad), byte(reg>>8), byte(reg), byte(val>>8), byte(val))) + return err +} + +func (b *bcm) eeprom(off byte, n int) ([]byte, error) { + return b.compound(0xA0, 0xA1, 500, n, []byte{off}) +} + +func (b *bcm) waitStatus(want func(uint16) bool) (uint16, error) { + var st uint16 + for i := 0; i < bcmStatusTries; i++ { + var err error + st, err = b.mdioRead(bcmMMDVendor, bcmRegStatus) + if err != nil { + return 0, err + } + if want(st) { + return st, nil + } + time.Sleep(bcmStatusPoll) + } + return 0, fmt.Errorf("%s: command handler stuck, status %#04x", b.ifname, st) +} + +// The handler never clears DATA registers it does not use, so every SET must +// pass its full parameter set and every GET must pass none. +func (b *bcm) command(code uint16, params ...uint16) ([5]uint16, error) { + b.mu.Lock() + defer b.mu.Unlock() + + var data [5]uint16 + if _, err := b.waitStatus(func(st uint16) bool { + return st != bcmStInProgress && st != bcmStBusy + }); err != nil { + return data, err + } + for i, p := range params { + if err := b.mdioWrite(bcmMMDVendor, bcmRegData1+uint16(i), p); err != nil { + return data, err + } + } + if err := b.mdioWrite(bcmMMDVendor, bcmRegCmd, code); err != nil { + return data, err + } + st, err := b.waitStatus(func(st uint16) bool { + return st == bcmStPass || st == bcmStError + }) + if err != nil { + return data, err + } + if st == bcmStError { + return data, fmt.Errorf("%s: command %#04x returned ERROR", b.ifname, code) + } + for i := range data { + data[i], err = b.mdioRead(bcmMMDVendor, bcmRegData1+uint16(i)) + if err != nil { + return data, err + } + } + return data, nil +} + +func (b *bcm) identify() (string, error) { + hi, err := b.mdioRead(1, 2) + if err != nil { + return "", err + } + lo, err := b.mdioRead(1, 3) + if err != nil { + return "", err + } + if hi != bcmPHYIDHi || lo != bcmPHYIDLo { + return "", fmt.Errorf("%s: PHY ID %#04x:%#04x, want %#04x:%#04x", + b.ifname, hi, lo, bcmPHYIDHi, bcmPHYIDLo) + } + sn, err := b.eeprom(68, 16) + if err != nil { + return "", err + } + return "BCM84891L sn " + strings.TrimSpace(string(sn)), nil +} + +// PMA 1.1 latches low, so the first read reports any drop since it was last +// read and the second reports the wire as it is now. +func (b *bcm) linkUp() (bool, error) { + if _, err := b.mdioRead(1, 1); err != nil { + return false, err + } + v, err := b.mdioRead(1, 1) + if err != nil { + return false, err + } + return v&0x0004 != 0, nil +} + +func (b *bcm) forceEEEOff() error { + _, err := b.command(bcmCmdSetEEEMode, 0x0000, 0x0000, 0x7A12, 0x0480, 0x0000) + return err +} + +func (b *bcm) restartAN() error { + v, err := b.mdioRead(7, 0) + if err != nil { + return err + } + return b.mdioWrite(7, 0, v|0x0200) +} + +func (b *bcm) eeeAdvert() (uint16, error) { + return b.mdioRead(7, 60) +} + +func (b *bcm) pairMap() (byte, error) { + d, err := b.command(bcmCmdGetPairSwap) + if err != nil { + return 0, err + } + return byte(d[1]), nil +} + +func (b *bcm) snr() ([4]float64, error) { + var out [4]float64 + d, err := b.command(bcmCmdGetSNR) + if err != nil { + return out, err + } + for i := range out { + out[i] = float64(d[i+1]) / 10 + } + return out, nil +} + +func (b *bcm) pcsLatch() (blocks, ber uint64, err error) { + v, err := b.mdioRead(3, 33) + if err != nil { + return 0, 0, err + } + return uint64(v & 0xFF), uint64((v >> 8) & 0x3F), nil +} + +func (b *bcm) fastRetrainCount() (uint16, error) { + v, err := b.mdioRead(1, 147) + if err != nil { + return 0, err + } + return v >> 11, nil +} + +type ecdResult struct { + verdicts [4]int + metres [4]int +} + +func (b *bcm) cableDiag() (ecdResult, error) { + b.mu.Lock() + defer b.mu.Unlock() + + var res ecdResult + ctrl, err := b.mdioRead(bcmMMDVendor, bcmRegECDCtrl) + if err != nil { + return res, err + } + if err := b.mdioWrite(bcmMMDVendor, bcmRegECDCtrl, ctrl&^0xF400|0x8400); err != nil { + return res, err + } + deadline := time.Now().Add(ecdDeadline) + for { + ctrl, err = b.mdioRead(bcmMMDVendor, bcmRegECDCtrl) + if err != nil { + return res, err + } + if ctrl&0x0800 == 0 { + break + } + if time.Now().After(deadline) { + return res, fmt.Errorf("%s: cable diag still busy after %s", b.ifname, ecdDeadline) + } + time.Sleep(ecdPoll) + } + v, err := b.mdioRead(1, bcmRegECDResult) + if err != nil { + return res, err + } + for i := range res.verdicts { + res.verdicts[i] = int(v>>(4*i)) & 0xF + m, err := b.mdioRead(1, bcmRegECDLen+uint16(i)) + if err != nil { + return res, err + } + res.metres[i] = int(m) + } + return res, nil +} + +const ( + phyInterval = time.Second + phyStale = 5 * time.Second + phyMaxDark = 30 + linkWaitSpan = 25 * time.Second + linkWaitPoll = time.Second + + snrOperatingPoint = 26.5 + snrGoodMargin = 3.0 + snrWarnMargin = 1.0 +) + +type phyModule struct { + bcm *bcm + + mu sync.Mutex + sampled bool + lastOK time.Time + link bool + haveSNR bool + snr [4]float64 + blocks uint64 + ber uint64 + retrains uint64 + recentDelta uint64 + primed bool + retrainCount uint16 +} + +func (m *phyModule) poll() error { + link, err := m.bcm.linkUp() + if err != nil { + return err + } + var snr [4]float64 + if link { + if snr, err = m.bcm.snr(); err != nil { + return err + } + } + blocks, ber, err := m.bcm.pcsLatch() + if err != nil { + return err + } + count, err := m.bcm.fastRetrainCount() + if err != nil { + return err + } + + m.mu.Lock() + m.sampled = true + m.lastOK = time.Now() + m.link = link + m.haveSNR = link + m.snr = snr + // The first poll after a baseline drains what the latches gathered during + // the bringup or diag retrain, which predates the run: it only establishes + // the origin. The retrain counter is 5 bits and rolls over, so only its + // forward motion is kept. + if m.primed { + delta := blocks + ber + uint64((count-m.retrainCount)&0x1F) + m.blocks += blocks + m.ber += ber + m.retrains += uint64((count - m.retrainCount) & 0x1F) + m.recentDelta = delta + } else { + m.recentDelta = 0 + m.primed = true + } + m.retrainCount = count + m.mu.Unlock() + return nil +} + +// A tester that quietly loses its SNR eye goes on reporting a clean link, so a +// transport that stays dark past every transient explanation stops the run. +func (m *phyModule) run(done *atomic.Bool) { + tick := time.NewTicker(phyInterval) + defer tick.Stop() + + dark := 0 + var lastErr error + for !done.Load() { + <-tick.C + if err := m.poll(); err != nil { + dark++ + lastErr = err + if dark >= phyMaxDark { + panic(fmt.Sprintf("module diagnostics dark for %d polls: %v", dark, lastErr)) + } + continue + } + dark = 0 + } +} + +func (m *phyModule) reset() { + m.mu.Lock() + m.blocks, m.ber, m.retrains, m.recentDelta = 0, 0, 0, 0 + m.primed = false + m.mu.Unlock() +} + +type phyModView struct { + fresh bool + link bool + margins [4]float64 + blocks uint64 + ber uint64 + retrain uint64 + recent uint64 +} + +func (m *phyModule) view() phyModView { + m.mu.Lock() + defer m.mu.Unlock() + v := phyModView{ + fresh: m.sampled && time.Since(m.lastOK) < phyStale, + link: m.link && m.haveSNR, + blocks: m.blocks, + ber: m.ber, + retrain: m.retrains, + } + if v.fresh { + v.recent = m.recentDelta + } + for i, s := range m.snr { + v.margins[i] = s - snrOperatingPoint + } + return v +} + +type cableInfo struct { + ecd ecdResult + maps [2]byte +} + +// The four pair lengths of one healthy cable disagree by a few metres of twist +// rate, so the cable's length is shown as their mean. +func (c cableInfo) metresString() string { + sum, n := 0, 0 + for i, v := range c.ecd.verdicts { + if v == pairOK { + sum += c.ecd.metres[i] + n++ + } + } + if n == 0 { + return "-" + } + return fmt.Sprintf("%d", (sum+n/2)/n) +} + +const ( + clsNone = iota + clsGood + clsWarn + clsBad +) + +func snrClass(margin float64) int { + switch { + case margin >= snrGoodMargin: + return clsGood + case margin >= snrWarnMargin: + return clsWarn + default: + return clsBad + } +} + +type phyDisplay struct { + haveSNR bool + worstMargin float64 + corrected uint64 + recent uint64 + metres string + metresClass int +} + +func pairLetter(i int) string { return string(rune('A' + i)) } + +// Each end resolves MDI on its own, so a swap at either end counts. +func pairSwapped(i int, maps [2]byte) bool { + return int(maps[0]>>(2*i))&3 != i || int(maps[1]>>(2*i))&3 != i +} + +// The cable as one figure and one judgment: the mean length of its healthy +// pairs, red when the diag found a fault, amber when a pair arrived swapped. +// Per-pair detail stays on the console — pair letters don't correlate back to +// wires by eye. +func cableSummary(cable cableInfo, measuring bool) (string, int) { + if measuring { + return "...", clsNone + } + anyData, anyFault, anySwap := false, false, false + for i, v := range cable.ecd.verdicts { + if v != 0 { + anyData = true + } + if v != 0 && v != pairOK { + anyFault = true + } + if pairSwapped(i, cable.maps) { + anySwap = true + } + } + s := cable.metresString() + switch { + case !anyData: + return "-", clsNone + case anyFault: + return s, clsBad + case anySwap: + return s, clsWarn + } + return s, clsGood +} + +// The worse of the two receivers' margins, worst pair across the cable. +func phyDisplayFrom(cable cableInfo, measuring bool, a, b phyModView) phyDisplay { + d := phyDisplay{ + haveSNR: a.fresh && b.fresh && a.link && b.link, + corrected: a.blocks + a.ber + a.retrain + b.blocks + b.ber + b.retrain, + recent: a.recent + b.recent, + } + if d.haveSNR { + d.worstMargin = min(a.margins[0], b.margins[0]) + for i := range a.margins { + if m := min(a.margins[i], b.margins[i]); m < d.worstMargin { + d.worstMargin = m + } + } + } + d.metres, d.metresClass = cableSummary(cable, measuring) + return d +} + +func waitLink(mods []*phyModule, done *atomic.Bool) (time.Duration, bool, error) { + start := time.Now() + deadline := start.Add(linkWaitSpan) + for { + up := true + for _, m := range mods { + v, err := m.bcm.linkUp() + if err != nil { + return 0, false, err + } + up = up && v + } + if up { + return time.Since(start), true, nil + } + if time.Now().After(deadline) || (done != nil && done.Load()) { + return time.Since(start), false, nil + } + time.Sleep(linkWaitPoll) + } +} + +// The whole cable picture in one pass: the ECD's per-pair verdicts and +// lengths, then — after the blip it causes has settled — both ends' pair +// maps, read post-link so the MDI resolution is the fresh one. +func measureCable(mods []*phyModule, waitRelink bool, done *atomic.Bool) (cableInfo, bool, error) { + var c cableInfo + var err error + c.ecd, err = mods[0].bcm.cableDiag() + if err != nil { + return c, false, err + } + relinked := false + if waitRelink { + if _, relinked, err = waitLink(mods, done); err != nil { + return c, false, err + } + } + for i, m := range mods { + if c.maps[i], err = m.bcm.pairMap(); err != nil { + return c, false, err + } + } + return c, relinked, nil +} + +// Owns the cable picture after bringup: a reset re-measures — the cable under +// a reset is usually a different cable — and the counters re-baseline only +// once the diag's own link blip is over, so it is never charged to the run. +type cableDiag struct { + mods []*phyModule + completed chan struct{} + + mu sync.Mutex + info cableInfo + running bool +} + +func newCableDiag(mods []*phyModule, info cableInfo) *cableDiag { + return &cableDiag{mods: mods, completed: make(chan struct{}, 1), info: info} +} + +func (c *cableDiag) snapshot() (cableInfo, bool) { + c.mu.Lock() + defer c.mu.Unlock() + return c.info, c.running +} + +// Runs the re-measure off the display loop, so the panel keeps drawing while +// the diag and the relink take their seconds. Reports whether one started; a +// press while one is in flight is absorbed. +func (c *cableDiag) kick(done *atomic.Bool) bool { + c.mu.Lock() + if c.running { + c.mu.Unlock() + return false + } + c.running = true + c.mu.Unlock() + + go func() { + defer holdPanic() + info, _, err := measureCable(c.mods, true, done) + if err != nil { + panic(err) + } + c.mu.Lock() + c.info = info + c.running = false + c.mu.Unlock() + select { + case c.completed <- struct{}{}: + default: + } + }() + return true +} + +func mapString(m byte) string { + if m == pairIdentityMap { + return "straight" + } + out := make([]string, 4) + for i := range out { + out[i] = pairLetter(int(m>>(2*i)) & 3) + } + return "swapped to " + strings.Join(out, "") +} + +func verdictString(r ecdResult) string { + bad := []string{} + for i, v := range r.verdicts { + if v != pairOK { + s, ok := pairVerdicts[v] + if !ok { + s = fmt.Sprintf("%d", v) + } + bad = append(bad, fmt.Sprintf("%s %s at %dm", pairLetter(i), s, r.metres[i])) + } + } + if len(bad) > 0 { + return strings.Join(bad, ", ") + } + return fmt.Sprintf("all pairs ok, %d/%d/%d/%d m", + r.metres[0], r.metres[1], r.metres[2], r.metres[3]) +} + +// Runs before any socket opens: forcing EEE off retrains the link and the ECD +// blips it, and both belong before the baselines rather than under them. +func moduleBringup(names [2]string) ([]*phyModule, cableInfo, []checkResult) { + var out []checkResult + var cable cableInfo + mods := make([]*phyModule, 0, 2) + fail := func(item string, err error) ([]*phyModule, cableInfo, []checkResult) { + return nil, cable, append(out, checkResult{item: item, err: err}) + } + + for _, name := range names { + res := checkResult{item: name + " module"} + b, err := openBCM(name) + if err != nil { + return fail(res.item, err) + } + res.state, err = b.identify() + if err != nil { + return fail(res.item, err) + } + out = append(out, res) + mods = append(mods, &phyModule{bcm: b}) + } + + for i, m := range mods { + res := checkResult{item: names[i] + " eee", fixed: true} + if err := m.bcm.forceEEEOff(); err != nil { + return fail(res.item, err) + } + if err := m.bcm.restartAN(); err != nil { + return fail(res.item, err) + } + res.state = "forced off, retraining" + out = append(out, res) + } + + res := checkResult{item: "link retrain"} + took, up, err := waitLink(mods, nil) + if err != nil { + return fail(res.item, err) + } + if up { + var adv [2]string + for i, m := range mods { + v, err := m.bcm.eeeAdvert() + if err != nil { + return fail(res.item, err) + } + adv[i] = fmt.Sprintf("%#04x", v) + if v != 0 { + res.err = fmt.Errorf("%s still advertises EEE %#04x", names[i], v) + } + } + res.state = fmt.Sprintf("up in %.1fs, eee advert %s/%s", took.Seconds(), adv[0], adv[1]) + } else { + res.state = "no link (cable unplugged?)" + } + out = append(out, res) + if res.err != nil { + return nil, cable, out + } + + res = checkResult{item: "cable diag"} + cable, relinked, err := measureCable(mods, up, nil) + if err != nil { + return fail(res.item, err) + } + res.state = verdictString(cable.ecd) + if up && !relinked { + res.err = fmt.Errorf("link did not return after cable diag") + } + out = append(out, res) + + for i := range mods { + out = append(out, checkResult{ + item: names[i] + " pair map", + state: mapString(cable.maps[i]), + }) + } + + return mods, cable, out +} diff --git a/phy_test.go b/phy_test.go new file mode 100644 index 0000000..b25ff7e --- /dev/null +++ b/phy_test.go @@ -0,0 +1,103 @@ +package main + +import "testing" + +func freshMod(margins [4]float64) phyModView { + return phyModView{fresh: true, link: true, margins: margins} +} + +// The worst pair at the worse end is what the margin cell shows, and a fault +// paints the length red: the live margins can only make the cable look worse, +// never repair a fault. +func TestPhyDisplayWorstMarginAndFault(t *testing.T) { + cable := cableInfo{ + ecd: ecdResult{verdicts: [4]int{pairOK, pairOpen, pairOK, pairOK}, metres: [4]int{45, 12, 41, 46}}, + maps: [2]byte{pairIdentityMap, pairIdentityMap}, + } + a := freshMod([4]float64{5, 5, 2, 0.5}) + b := freshMod([4]float64{4, 5, 5, 5}) + d := phyDisplayFrom(cable, false, a, b) + + if !d.haveSNR || d.worstMargin != 0.5 { + t.Errorf("worstMargin = %v (have %v), want 0.5", d.worstMargin, d.haveSNR) + } + if d.metres != "44" || d.metresClass != clsBad { + t.Errorf("metres = %q class %d, want the healthy mean 44 painted bad", d.metres, d.metresClass) + } +} + +// A swap at either end marks the cable: the far end resolving MDI-X on its +// own is the usual way a crossover shows up. +func TestCableSummary(t *testing.T) { + healthy := ecdResult{verdicts: [4]int{pairOK, pairOK, pairOK, pairOK}, metres: [4]int{50, 48, 47, 51}} + for _, c := range []struct { + name string + cable cableInfo + measuring bool + want string + class int + }{ + {"clean", cableInfo{ecd: healthy, maps: [2]byte{pairIdentityMap, pairIdentityMap}}, false, "49", clsGood}, + {"far-end swap", cableInfo{ecd: healthy, maps: [2]byte{pairIdentityMap, 0xE1}}, false, "49", clsWarn}, + {"no diag yet", cableInfo{}, false, "-", clsNone}, + {"measuring", cableInfo{ecd: healthy, maps: [2]byte{pairIdentityMap, pairIdentityMap}}, true, "...", clsNone}, + } { + s, cl := cableSummary(c.cable, c.measuring) + if s != c.want || cl != c.class { + t.Errorf("%s = %q class %d, want %q class %d", c.name, s, cl, c.want, c.class) + } + } +} + +func TestPhyDisplayStaleGoesDim(t *testing.T) { + cable := cableInfo{ + ecd: ecdResult{verdicts: [4]int{pairOK, pairOK, pairOK, pairOK}, metres: [4]int{45, 45, 41, 46}}, + maps: [2]byte{pairIdentityMap, pairIdentityMap}, + } + d := phyDisplayFrom(cable, false, freshMod([4]float64{5, 5, 5, 5}), phyModView{}) + if d.haveSNR { + t.Error("one silent module should withhold snr") + } + if d.metres != "44" || d.metresClass != clsGood { + t.Errorf("metres = %q class %d, want the diag verdict kept", d.metres, d.metresClass) + } +} + +// The mean skips faulted pairs, whose length is a distance to the fault +// rather than a length of the cable. +func TestCableMetresString(t *testing.T) { + c := cableInfo{ecd: ecdResult{ + verdicts: [4]int{pairOK, pairOpen, pairOK, pairOK}, + metres: [4]int{45, 3, 41, 46}, + }} + if got := c.metresString(); got != "44" { + t.Errorf("metres = %q, want 44", got) + } + if got := (cableInfo{}).metresString(); got != "-" { + t.Errorf("no diag = %q, want -", got) + } +} + +func TestSNRClass(t *testing.T) { + for _, c := range []struct { + margin float64 + want int + }{ + {5, clsGood}, {3, clsGood}, {2, clsWarn}, {1, clsWarn}, {0.5, clsBad}, {-2, clsBad}, + } { + if got := snrClass(c.margin); got != c.want { + t.Errorf("snrClass(%v) = %d, want %d", c.margin, got, c.want) + } + } +} + +func TestVerdictString(t *testing.T) { + ok := ecdResult{verdicts: [4]int{1, 1, 1, 1}, metres: [4]int{45, 45, 41, 46}} + if got := verdictString(ok); got != "all pairs ok, 45/45/41/46 m" { + t.Errorf("healthy = %q", got) + } + bad := ecdResult{verdicts: [4]int{1, 2, 4, 1}, metres: [4]int{45, 12, 30, 46}} + if got := verdictString(bad); got != "B OPEN at 12m, C XTALK at 30m" { + t.Errorf("faulted = %q", got) + } +} diff --git a/probe.go b/probe.go deleted file mode 100644 index 2a8446e..0000000 --- a/probe.go +++ /dev/null @@ -1,226 +0,0 @@ -package main - -import ( - "sync" - "sync/atomic" - "time" - - "golang.org/x/sys/unix" -) - -const ( - // Above any real stream number, so a probe is never taken for payload. - probeStream = 0xffff - probeSize = 64 - probePattern = 0 - - // The mac has only a handful of transmit stamp slots. Asking faster than it - // can drain them gets slots recycled while a stamp is still outstanding, and - // the one that comes back then belongs to a different frame. - probeInterval = 200 * time.Millisecond - probeTimeout = 20 * time.Millisecond - // A phy costs microseconds and a hundred metres of copper costs five hundred - // nanoseconds, so anything past this is a broken stamp, not a slow frame. - probeMaxDelay = 50 * time.Microsecond - probePendCap = 256 -) - -// Both ports hang off one PTP clock, so a transmit stamp from one and a receive -// stamp from the other subtract directly. Both are taken at the mac, so all host -// time and all queueing falls outside the stamped interval, which is why load -// does not move it. -type cableStats struct { - mu sync.Mutex - min int64 - samples uint64 - floor int64 - - txPend map[uint64]int64 - rxPend map[uint64]int64 -} - -type cableView struct { - min int64 - floor int64 - ok bool -} - -// Averaging the two directions cancels the phy asymmetry between them, which is -// about 790ns and swamps any cable, so one direction alone cannot give a length. -func cableMetres(views []view, nsPerM float64) (float64, bool) { - if len(views) == 0 { - return 0, false - } - var excess float64 - for _, v := range views { - if !v.cable.ok { - return 0, false - } - excess += float64(v.cable.min - v.cable.floor) - } - return excess / float64(len(views)) / nsPerM, true -} - -func newCableStats() *cableStats { - return &cableStats{ - txPend: make(map[uint64]int64, probePendCap), - rxPend: make(map[uint64]int64, probePendCap), - } -} - -func (c *cableStats) put(seq uint64, ts int64, tx bool) { - c.mu.Lock() - defer c.mu.Unlock() - - mine, theirs := c.txPend, c.rxPend - if !tx { - mine, theirs = c.rxPend, c.txPend - } - other, ok := theirs[seq] - if !ok { - if len(mine) >= probePendCap { - clear(mine) - } - mine[seq] = ts - return - } - delete(theirs, seq) - - delta := ts - other - if tx { - delta = -delta - } - // The driver rebuilds a full timestamp from a truncated hardware value plus a - // cached clock read, and a stale cache lands hundreds of milliseconds out. A - // minimum would latch onto the first of those and never recover. - if delta <= 0 || delta > int64(probeMaxDelay) { - return - } - if c.samples == 0 || delta < c.min { - c.min = delta - } - if c.floor == 0 || delta < c.floor { - c.floor = delta - } - c.samples++ -} - -func (c *cableStats) view() cableView { - c.mu.Lock() - defer c.mu.Unlock() - return cableView{c.min, c.floor, c.samples > 0} -} - -func (c *cableStats) reset() { - c.mu.Lock() - c.min, c.samples = 0, 0 - clear(c.txPend) - clear(c.rxPend) - c.mu.Unlock() -} - -type probeSender struct { - fd int - spec *frameSpec - stats *cableStats -} - -func (p *probeSender) run(done *atomic.Bool, startTx <-chan struct{}) { - buf := make([]byte, probeSize) - p.spec.prefill(buf, probePattern) - oob := make([]byte, 512) - scratch := make([]byte, 1) - - <-startTx - - tick := time.NewTicker(probeInterval) - defer tick.Stop() - - var seq uint64 - for !done.Load() { - <-tick.C - - // Stamps are matched to sends by position in the queue, so one that - // arrived after its probe gave up would be handed to this probe. - for { - if _, _, _, _, err := unix.Recvmsg(p.fd, scratch, oob, - unix.MSG_ERRQUEUE|unix.MSG_DONTWAIT); err != nil { - break - } - } - - putHeader(buf, probePattern, probeStream, seq, probeSize-minFrame) - err := unix.Send(p.fd, buf, 0) - // The sequence advances even when a probe fails, so a stale receive half - // can never be paired with a later probe that reused its number. - cur := seq - seq++ - if err != nil { - continue - } - ts, ok := p.awaitTx(scratch, oob) - if !ok { - continue - } - p.stats.put(cur, ts, true) - } -} - -func (p *probeSender) awaitTx(scratch, oob []byte) (int64, bool) { - fds := []unix.PollFd{{Fd: int32(p.fd), Events: unix.POLLERR}} - deadline := time.Now().Add(probeTimeout) - for { - left := time.Until(deadline) - if left <= 0 { - return 0, false - } - // Rounded up, since truncating would give up with time still on the - // clock and shave the last fraction of a millisecond off every wait. - ms := int((left + time.Millisecond - 1) / time.Millisecond) - n, err := unix.Poll(fds, ms) - if err == unix.EINTR { - continue - } - if err != nil || n == 0 { - return 0, false - } - _, oobn, _, _, err := unix.Recvmsg(p.fd, scratch, oob, - unix.MSG_ERRQUEUE|unix.MSG_DONTWAIT) - if err == unix.EAGAIN || err == unix.EINTR { - continue - } - if err != nil { - return 0, false - } - return hwTimestamp(oob[:oobn]) - } -} - -type probeReceiver struct { - fd int - stats *cableStats - ready *sync.WaitGroup -} - -func (r *probeReceiver) run(done *atomic.Bool) { - buf := make([]byte, maxFrame) - oob := make([]byte, 512) - - r.ready.Done() - - for !done.Load() { - n, oobn, _, _, err := unix.Recvmsg(r.fd, buf, oob, 0) - if err != nil { - continue - } - h, st := parseHeader(buf[:n]) - if st != hdrOK || h.stream != probeStream { - continue - } - ts, ok := hwTimestamp(oob[:oobn]) - if !ok { - continue - } - r.stats.put(h.seq, ts, false) - } -} diff --git a/probe_test.go b/probe_test.go deleted file mode 100644 index 72156b7..0000000 --- a/probe_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package main - -import "testing" - -func stamped(min, floor int64) view { - return view{cable: cableView{min: min, floor: floor, ok: true}} -} - -// Averaged across directions, since one direction alone carries a phy asymmetry -// that swamps the cable. -func TestCableMetresAveragesDirections(t *testing.T) { - m, ok := cableMetres([]view{stamped(1000, 900), stamped(1100, 900)}, 5) - if !ok || m != 30 { - t.Errorf("cableMetres = %v, %v; want 30, true", m, ok) - } -} - -func TestCableMetresNeedsEveryDirection(t *testing.T) { - if _, ok := cableMetres(nil, 5); ok { - t.Error("no views should not yield a length") - } - if _, ok := cableMetres([]view{stamped(1000, 900), {}}, 5); ok { - t.Error("a direction with no stamp yet should not yield a length") - } -} diff --git a/render.go b/render.go index f0d5262..241f10b 100644 --- a/render.go +++ b/render.go @@ -275,6 +275,39 @@ func statusCell(v uint64) string { return paint(s, cRed) } +// The worst pair margin across both receivers, in dB above the 10GBASE-T +// operating point, so nobody has to know the operating point to read it. +func snrCell(phy phyDisplay) string { + if !phy.haveSNR { + return paint("-", cGrey) + } + s := fmt.Sprintf("%+.1f", phy.worstMargin) + switch snrClass(phy.worstMargin) { + case clsGood: + return paint(s, cGreen) + case clsWarn: + return paint(s, cYellow) + default: + return paint(s, cRed) + } +} + +// Errors the phy absorbed before they could cost a frame: yellow rather than +// red, the cable being stressed rather than failing. +func correctedCell(v uint64) string { + if v == 0 { + return paint("0", cGreen) + } + return paint(commas(v), cYellow) +} + +func correctedFlag(v uint64) string { + if v == 0 { + return paint("ok", cGreen) + } + return paint("warn", cYellow) +} + // Per-interval rates jitter by a couple of percent at line rate, so green has // to cover that. Yellow means a real shortfall, red means badly off. const ( diff --git a/system.go b/system.go index 3121391..390d4c3 100644 --- a/system.go +++ b/system.go @@ -567,18 +567,40 @@ func checkCoalesce(fd int, ifname string, rxUsecs, txUsecs uint32) checkResult { return fmt.Sprintf("adaptive rx=%d tx=%d rx-usecs=%d tx-usecs=%d", e.useAdaptiveRxCoalesce, e.useAdaptiveTxCoalesce, e.rxCoalesceUsecs, e.txCoalesceUsecs) } - if ec.useAdaptiveRxCoalesce == 0 && ec.useAdaptiveTxCoalesce == 0 && - ec.rxCoalesceUsecs == rxUsecs && ec.txCoalesceUsecs == txUsecs { + settled := func(e ethtoolCoalesce, tx uint32) bool { + return e.useAdaptiveRxCoalesce == 0 && e.useAdaptiveTxCoalesce == 0 && + e.rxCoalesceUsecs == rxUsecs && e.txCoalesceUsecs == tx + } + if settled(ec, txUsecs) { res.state = desc(ec) return res } was := desc(ec) - ec.cmd = unix.ETHTOOL_SCOALESCE - ec.useAdaptiveRxCoalesce = 0 - ec.useAdaptiveTxCoalesce = 0 - ec.rxCoalesceUsecs = rxUsecs - ec.txCoalesceUsecs = txUsecs - if err := ethtoolCall(fd, ifname, unsafe.Pointer(&ec)); err != nil { + set := func(tx uint32) error { + s := ec + s.cmd = unix.ETHTOOL_SCOALESCE + s.useAdaptiveRxCoalesce = 0 + s.useAdaptiveTxCoalesce = 0 + s.rxCoalesceUsecs = rxUsecs + s.txCoalesceUsecs = tx + return ethtoolCall(fd, ifname, unsafe.Pointer(&s)) + } + err = set(txUsecs) + // A driver running mixed rx/tx vectors has one moderation value per + // vector and rejects a tx-specific one: rx-usecs covers both, and get + // reports the tx side as zero. + if err == unix.EINVAL { + if settled(ec, 0) { + res.state = desc(ec) + " (tx shares rx)" + return res + } + if err = set(0); err == nil { + res.fixed = true + res.state = fmt.Sprintf("was %s, now adaptive off rx-usecs=%d shared with tx", was, rxUsecs) + return res + } + } + if err != nil { res.err = err res.state = "could not set" return res diff --git a/ts.go b/ts.go index bb2903e..22ab78a 100644 --- a/ts.go +++ b/ts.go @@ -8,7 +8,7 @@ import ( ) const ( - hwtstampTxOn = 1 + hwtstampTxOff = 0 hwtstampFilterAll = 1 ) @@ -18,9 +18,23 @@ type hwtstampConfig struct { rxFilter int32 } +// Temporarily bypassed rather than fatal: the X520 bench card cannot stamp +// all packets, and the BCM diagnostics path needs runs now. Without stamps +// the rate buckets never fill, so the panel's rates read zero; everything +// else still measures. +func checkTimestamping(fd int, ifname string) checkResult { + res := configureTimestamping(fd, ifname) + if res.err != nil { + res.state = "bypassed: " + res.detail() + res.err = nil + res.fixed = true + } + return res +} + // Receive stamping is filtered by protocol and ours is not PTP, so nothing // narrower than "all" will see our frames. -func checkTimestamping(fd int, ifname string) checkResult { +func configureTimestamping(fd int, ifname string) checkResult { res := checkResult{item: ifname + " hw timestamps"} desc := func(c hwtstampConfig) string { return fmt.Sprintf("tx_type=%d rx_filter=%d", c.txType, c.rxFilter) @@ -42,20 +56,20 @@ func checkTimestamping(fd int, ifname string) checkResult { res.err = err return res } - if have.txType == hwtstampTxOn && have.rxFilter == hwtstampFilterAll { + if have.txType == hwtstampTxOff && have.rxFilter == hwtstampFilterAll { res.state = desc(have) return res } // The ioctl reports back what the driver actually applied, which can be // narrower than what was asked for. - want := hwtstampConfig{txType: hwtstampTxOn, rxFilter: hwtstampFilterAll} + want := hwtstampConfig{txType: hwtstampTxOff, rxFilter: hwtstampFilterAll} if err := hwtstampCall(unix.SIOCSHWTSTAMP, &want); err != nil { res.err = err res.state = "could not set" return res } - if want.txType != hwtstampTxOn || want.rxFilter != hwtstampFilterAll { + if want.txType != hwtstampTxOff || want.rxFilter != hwtstampFilterAll { res.err = fmt.Errorf("driver applied %s instead", desc(want)) return res } @@ -64,13 +78,6 @@ func checkTimestamping(fd int, ifname string) checkResult { return res } -func enableTxTimestamps(fd int) error { - return unix.SetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_TIMESTAMPING, - unix.SOF_TIMESTAMPING_TX_HARDWARE| - unix.SOF_TIMESTAMPING_RAW_HARDWARE| - unix.SOF_TIMESTAMPING_OPT_TSONLY) -} - func enableRxTimestamps(fd int) error { return unix.SetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_TIMESTAMPING, unix.SOF_TIMESTAMPING_RX_HARDWARE|unix.SOF_TIMESTAMPING_RAW_HARDWARE) diff --git a/ui.go b/ui.go index 6fc311b..a421f27 100644 --- a/ui.go +++ b/ui.go @@ -17,9 +17,22 @@ var ( uiDim = rgb{0x9a, 0xa2, 0xac} uiCyan = rgb{0x5c, 0xc8, 0xe0} uiGreen = rgb{0x6c, 0xdc, 0x86} + uiAmber = rgb{0xe8, 0xc4, 0x52} uiRed = rgb{0xf0, 0x6b, 0x6b} ) +func classColor(c int) rgb { + switch c { + case clsGood: + return uiGreen + case clsWarn: + return uiAmber + case clsBad: + return uiRed + } + return uiDim +} + // Distances between ink, since layout measures a line from the top of a digit // to the baseline rather than across a cell with accent and descender slack in // it. Values carried over from spacing cells will look too small here. @@ -48,7 +61,7 @@ const ( chipBorder = 2 gridCols = 2 - chipPadY = spaceGroup + chipPadY = step * 3 chipGap = spaceTight statRowGap = spaceRow ) @@ -92,9 +105,9 @@ func newDisplay() (*display, error) { bold bool size float64 }{ - {&d.big, true, 40}, - {&d.grid, false, 34}, - {&d.gridB, true, 34}, + {&d.big, true, 36}, + {&d.grid, false, 30}, + {&d.gridB, true, 30}, } { face, err := loadFace(spec.bold, spec.size) if err != nil { @@ -108,22 +121,29 @@ func newDisplay() (*display, error) { return nil, fmt.Errorf("grid faces disagree on cell width: %d vs %d", d.grid.cellW, d.gridB.cellW) } - now := []int{d.statsH(d.big, 2), d.chipsH()} - since := []int{d.statsH(d.gridB, 4), d.countsH(), btnH} + if err := d.layout(fb.w, fb.h); err != nil { + fb.close() + return nil, err + } + return d, nil +} + +func (d *display) layout(w, h int) error { + now := []int{d.statsH(d.big, 3), d.chipsH()} + since := []int{d.statsH(d.gridB, 4), d.statsH(d.gridB, 1), d.countsH(), btnH} // One gap for both panels, and vertically the frame is the border alone. // Insetting by uiPad as well would add it to the gaps at a panel's ends but // not to the ones between blocks, which is not equal spacing however evenly // the remainder is divided. gaps := len(now) + len(since) + 2 - spare := fb.h - 2*uiMargin - blockGap - 4*uiBorder - sum(now) - sum(since) + spare := h - 2*uiMargin - blockGap - 4*uiBorder - sum(now) - sum(since) if spare < 0 { - fb.close() - return nil, fmt.Errorf("panel content is %dpx taller than the screen", -spare) + return fmt.Errorf("panel content is %dpx taller than the screen", -spare) } gap := spare / gaps - inner := fb.w - 2*uiMargin + inner := w - 2*uiMargin nowH := 2*uiBorder + sum(now) + (len(now)+1)*gap sinceH := 2*uiBorder + sum(since) + (len(since)+1)*gap d.nowPanel = rect{uiMargin, uiMargin, inner, nowH} @@ -137,8 +157,8 @@ func newDisplay() (*display, error) { w: btnW, h: btnH, } - d.versionSpot = rect{fb.w - versionSpotSide, 0, versionSpotSide, versionSpotSide} - return d, nil + d.versionSpot = rect{w - versionSpotSide, 0, versionSpotSide, versionSpotSide} + return nil } func sum(hs []int) int { @@ -270,10 +290,10 @@ func (d *display) statsH(vf *textFace, n int) int { } // A last row that does not fill the grid is centred. -func gridCell(i, n, x, w int) (cx, cw int) { - cw = (w - (gridCols-1)*chipGap) / gridCols - inRow := min(n-(i/gridCols)*gridCols, gridCols) - cx = x + (w-(inRow*cw+(inRow-1)*chipGap))/2 + (i%gridCols)*(cw+chipGap) +func gridCell(i, n, cols, x, w int) (cx, cw int) { + cw = (w - (cols-1)*chipGap) / cols + inRow := min(n-(i/cols)*cols, cols) + cx = x + (w-(inRow*cw+(inRow-1)*chipGap))/2 + (i%cols)*(cw+chipGap) return cx, cw } @@ -284,7 +304,7 @@ func (d *display) stats(vf *textFace, x, w, y int, cells []statCell) int { if c.value == "" { continue } - cx, cw := gridCell(i, len(cells), x, w) + cx, cw := gridCell(i, len(cells), gridCols, x, w) cy := y + (i/gridCols)*(d.statPairH(vf)+statRowGap) ly := d.centerIn(vf, cx, cw, cy, c.value, c.col) d.centerIn(d.grid, cx, cw, ly, c.label, uiDim) @@ -306,9 +326,10 @@ func (d *display) chipH() int { return d.grid.lineH + 2*chipPadY } func (d *display) countChipH() int { return d.chipH() + d.gridB.lineH + pairGap } -// The error chips plus the noise cable's, which shares their row grid. +// The error chips plus corrected and the noise cable's, which share their +// row grid. func (d *display) chipsH() int { - return gridRows(len(errRows)+1)*(d.chipH()+chipGap) - chipGap + return gridRows(len(errRows)+2)*(d.chipH()+chipGap) - chipGap } func (d *display) countsH() int { @@ -317,9 +338,9 @@ func (d *display) countsH() int { // Outlined by drawing the border colour and sinking a smaller well of // background into it, so both curves get the same antialiasing. -func (d *display) chipAt(i, n, x, w, y, h int, c rgb) (int, int, int) { - cx, cw := gridCell(i, n, x, w) - cy := y + (i/gridCols)*(h+chipGap) +func (d *display) chipAt(i, n, cols, x, w, y, h int, c rgb) (int, int, int) { + cx, cw := gridCell(i, n, cols, x, w) + cy := y + (i/cols)*(h+chipGap) d.fb.roundRect(cx, cy, cw, h, chipRadius, c) d.fb.roundRect(cx+chipBorder, cy+chipBorder, @@ -330,15 +351,22 @@ func (d *display) chipAt(i, n, x, w, y, h int, c rgb) (int, int, int) { // Whether rather than how many: over a window this short a count changes faster // than it can be read. The noise chip rides along at the end, presence rather // than health: red is the cable missing, not the cable failing. -func (d *display) errChips(x, w, y int, e errs, noiseMissing uint64) int { - n := len(errRows) + 1 +func (d *display) errChips(x, w, y int, e errs, recentCorrected, noiseMissing uint64) int { + n := len(errRows) + 2 for i, r := range errRows { c := errColor(r.get(e)) - cx, cw, cy := d.chipAt(i, n, x, w, y, d.chipH(), c) + cx, cw, cy := d.chipAt(i, n, gridCols, x, w, y, d.chipH(), c) d.centerIn(d.grid, cx, cw, cy+chipPadY, r.label, c) } - c := errColor(noiseMissing) - cx, cw, cy := d.chipAt(len(errRows), n, x, w, y, d.chipH(), c) + // Amber rather than red: the phy absorbed these before they cost a frame. + c := uiGreen + if recentCorrected > 0 { + c = uiAmber + } + cx, cw, cy := d.chipAt(len(errRows), n, gridCols, x, w, y, d.chipH(), c) + d.centerIn(d.grid, cx, cw, cy+chipPadY, "corrected", c) + c = errColor(noiseMissing) + cx, cw, cy = d.chipAt(len(errRows)+1, n, gridCols, x, w, y, d.chipH(), c) d.centerIn(d.grid, cx, cw, cy+chipPadY, "noise", c) return y + d.chipsH() } @@ -347,16 +375,26 @@ func (d *display) errCounts(x, w, y int, e errs) int { for i, r := range errRows { n := r.get(e) c := errColor(n) - cx, cw, cy := d.chipAt(i, len(errRows), x, w, y, d.countChipH(), c) + cx, cw, cy := d.chipAt(i, len(errRows), gridCols, x, w, y, d.countChipH(), c) ty := d.centerIn(d.gridB, cx, cw, cy+chipPadY, scaleCount(n), c) d.centerIn(d.grid, cx, cw, ty, r.label, c) } return y + d.countsH() } -func (d *display) panel(p rect, e errs) (int, int) { +// White when the wiring is clean: a measurement rather than a judgment. The +// colours are reserved for the diag finding a fault or a swapped pair. +func metresStat(phy phyDisplay) statCell { + col := uiFg + if phy.metresClass != clsGood { + col = classColor(phy.metresClass) + } + return statCell{phy.metres, "m", col} +} + +func (d *display) panel(p rect, bad bool) (int, int) { fill, edge := uiOKFill, uiOKEdge - if e.total() > 0 { + if bad { fill, edge = uiErrFil, uiErrEdg } d.fb.rect(p.x, p.y, p.w, p.h, edge) @@ -374,25 +412,45 @@ func errColor(n uint64) rgb { return uiRed } -func (d *display) render(v view, elapsed time.Duration, cable string, noiseMissing uint64) error { +func snrStat(phy phyDisplay) statCell { + if !phy.haveSNR { + return statCell{"-", "db margin", uiDim} + } + return statCell{fmt.Sprintf("%+.1f", phy.worstMargin), "db margin", + classColor(snrClass(phy.worstMargin))} +} + +// Errors the phy absorbed before they could cost a frame: amber rather than +// red, the cable being stressed rather than failing. +func correctedStat(v uint64) statCell { + col := uiGreen + if v > 0 { + col = uiAmber + } + return statCell{scaleCount(v), "corrected", col} +} + +func (d *display) render(v view, elapsed time.Duration, phy phyDisplay, noiseMissing uint64) error { fb := d.fb fb.fill(uiBg) - x, w := d.panel(d.nowPanel, v.window) + x, w := d.panel(d.nowPanel, v.window.total() > 0) d.stats(d.big, x, w, d.nowYs[0], []statCell{ {scaleSI(v.rxGbps * 1e9), "bits/s", uiFg}, {scaleSI(v.rxPPS), "packets/s", uiFg}, + snrStat(phy), }) - d.errChips(x, w, d.nowYs[1], v.window, noiseMissing) + d.errChips(x, w, d.nowYs[1], v.window, phy.recent, noiseMissing) - x, w = d.panel(d.sincePanel, v.since) + x, w = d.panel(d.sincePanel, v.since.total() > 0 || phy.metresClass == clsBad) d.stats(d.gridB, x, w, d.sinceYs[0], []statCell{ {scaleTime(elapsed), "elapsed", uiFg}, {scaleCount(v.rxFrames), "packets", uiFg}, {scaleCount(v.rxBytes), "bytes", uiFg}, - {cable, "m", uiFg}, + correctedStat(phy.corrected), }) - d.errCounts(x, w, d.sinceYs[1], v.since) + d.stats(d.gridB, x, w, d.sinceYs[1], []statCell{metresStat(phy)}) + d.errCounts(x, w, d.sinceYs[2], v.since) d.drawResetButton() return fb.flush() diff --git a/ui_test.go b/ui_test.go index b1d2e23..2fdc2df 100644 --- a/ui_test.go +++ b/ui_test.go @@ -3,8 +3,8 @@ package main import "testing" func TestGridCellFullRow(t *testing.T) { - x0, w0 := gridCell(0, 2, 0, 100) - x1, w1 := gridCell(1, 2, 0, 100) + x0, w0 := gridCell(0, 2, gridCols, 0, 100) + x1, w1 := gridCell(1, 2, gridCols, 0, 100) if w0 != w1 { t.Errorf("cells differ in width: %d vs %d", w0, w1) } @@ -21,8 +21,37 @@ func TestGridCellFullRow(t *testing.T) { // A last row that does not fill the grid is centred. func TestGridCellShortLastRow(t *testing.T) { - cx, cw := gridCell(2, 3, 0, 100) + cx, cw := gridCell(2, 3, gridCols, 0, 100) if left, right := cx, 100-(cx+cw); left != right { t.Errorf("lone cell has %d left and %d right, want centred", left, right) } } + +// The panel is a fixed 600x1024, so whether everything fits is decidable here +// rather than on the hardware, with enough spare that the gaps stay readable. +func TestPanelFitsScreen(t *testing.T) { + d := &display{} + for _, spec := range []struct { + dst **textFace + bold bool + size float64 + }{ + {&d.big, true, 40}, + {&d.grid, false, 34}, + {&d.gridB, true, 34}, + } { + face, err := loadFace(spec.bold, spec.size) + if err != nil { + t.Fatal(err) + } + *spec.dst = face + } + if err := d.layout(600, 1024); err != nil { + t.Fatal(err) + } + gap := d.nowYs[0] - (d.nowPanel.y + uiBorder) + t.Logf("panel gap %dpx, since panel ends at %dpx", gap, d.sincePanel.y+d.sincePanel.h) + if gap < spaceTight { + t.Errorf("panel gap is %dpx, want at least %d", gap, spaceTight) + } +}