Bugs-evident cleanup: cable-measure failure is fatal with its reason (failed/fail pretend-path and dead relinked plumbing deleted), bcm.command refuses partial SETs (0 or 5 params, stale-DATA trap made structural), BOOT gains a patched-ixgbe check naming stock-driver boots, panel fault rows matched by label and console rows strict on column count, phy.go split into sff/bcm/rollball and main.go's direction machinery into direction.go (pure moves), input events decoded via encoding/binary, gofmt; verified on hardware (20.00G, zero errors, ECD 41m)
This commit is contained in:
@@ -0,0 +1,307 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
bcmCmdSetJumbo uint16 = 0x801C
|
||||||
|
|
||||||
|
bcmRegECDCtrl uint16 = 0x4006
|
||||||
|
bcmRegECDResult uint16 = 0xA896
|
||||||
|
bcmRegECDLen uint16 = 0xA897
|
||||||
|
|
||||||
|
bcmPHYIDHi = 0x3590
|
||||||
|
bcmPHYIDLo = 0x5081
|
||||||
|
|
||||||
|
bcmCmdResidentTemp uint16 = 0x0031
|
||||||
|
|
||||||
|
bcmReadDelayUs = 3000
|
||||||
|
bcmRetryDelayUs = 10000
|
||||||
|
|
||||||
|
bcmWindow = 3400 * time.Millisecond
|
||||||
|
bcmWindowFit = 100 * time.Millisecond
|
||||||
|
bcmFlipPoll = 10 * time.Millisecond
|
||||||
|
bcmFlipWait = 5 * time.Second
|
||||||
|
|
||||||
|
bcmStatusPoll = 100 * time.Millisecond
|
||||||
|
// Covers the handler's documented 2 s freeze during 10GBASE-T training.
|
||||||
|
bcmStatusTimeout = 3 * time.Second
|
||||||
|
|
||||||
|
ecdPoll = 200 * time.Millisecond
|
||||||
|
ecdDeadline = 50 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type bcm struct {
|
||||||
|
*sff
|
||||||
|
windowEnd time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBCM(t *sff) *bcm {
|
||||||
|
b := &bcm{sff: t}
|
||||||
|
t.exec(func() { t.admit = b.window })
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// The firmware's internal temp poll serves stale bridge reads for ~50 ms
|
||||||
|
// around it; work stays inside 3.4 s of an observed poll. A resident 0x0031 at
|
||||||
|
// expiry means the phase is unknown, so re-lock: arm, then take the true edge.
|
||||||
|
// Each taken edge immediately re-arms — the one CMD write per window lands at
|
||||||
|
// the start of the quiet period, maximally far from the next poll (writes near
|
||||||
|
// the poll are the µC-wedge risk), and every later expiry reads the phase
|
||||||
|
// without writing.
|
||||||
|
func (b *bcm) window() {
|
||||||
|
if time.Now().Add(bcmWindowFit).Before(b.windowEnd) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
armed := false
|
||||||
|
deadline := time.Now().Add(bcmFlipWait)
|
||||||
|
for {
|
||||||
|
v, err := b.mdioRead(bcmMMDVendor, bcmRegCmd)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("%s: heartbeat poll: %v", b.ifname, err))
|
||||||
|
}
|
||||||
|
if v == bcmCmdResidentTemp {
|
||||||
|
if armed {
|
||||||
|
b.windowEnd = time.Now().Add(bcmWindow)
|
||||||
|
b.rearm()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.rearm()
|
||||||
|
armed = true
|
||||||
|
deadline = time.Now().Add(bcmFlipWait)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
armed = true
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
b.windowEnd = time.Now().Add(bcmWindow)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(bcmFlipPoll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bcm) rearm() {
|
||||||
|
if _, err := b.waitStatus(func(st uint16) bool {
|
||||||
|
return st != bcmStInProgress && st != bcmStBusy
|
||||||
|
}); err != nil {
|
||||||
|
panic(fmt.Sprintf("%s: rearm: %v", b.ifname, err))
|
||||||
|
}
|
||||||
|
if err := b.mdioWrite(bcmMMDVendor, bcmRegCmd, bcmCmdGetPairSwap); err != nil {
|
||||||
|
panic(fmt.Sprintf("%s: rearm: %v", b.ifname, err))
|
||||||
|
}
|
||||||
|
if _, err := b.waitStatus(func(st uint16) bool {
|
||||||
|
return st == bcmStPass || st == bcmStError
|
||||||
|
}); err != nil {
|
||||||
|
panic(fmt.Sprintf("%s: rearm: %v", b.ifname, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// The datasheet's completion handshake: poll STATUS on its 100 ms cadence
|
||||||
|
// until the wanted state, bounded by a deadline.
|
||||||
|
func (b *bcm) waitStatus(want func(uint16) bool) (uint16, error) {
|
||||||
|
deadline := time.Now().Add(bcmStatusTimeout)
|
||||||
|
for {
|
||||||
|
st, err := b.mdioRead(bcmMMDVendor, bcmRegStatus)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if want(st) {
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return 0, fmt.Errorf("%s: command handler stuck, status %#04x", b.ifname, st)
|
||||||
|
}
|
||||||
|
time.Sleep(bcmStatusPoll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GETs must be invoked bare (pre-writing any DATA register leaves the handler
|
||||||
|
// executing as a no-op); SETs must write every DATA register (the handler
|
||||||
|
// executes stale DATA), so a partial parameter set is refused outright.
|
||||||
|
func (b *bcm) command(code uint16, params ...uint16) (data [5]uint16, err error) {
|
||||||
|
if len(params) != 0 && len(params) != len(data) {
|
||||||
|
panic(fmt.Sprintf("%s: command %#04x with %d params: a SET must write all %d DATA registers",
|
||||||
|
b.ifname, code, len(params), len(data)))
|
||||||
|
}
|
||||||
|
b.exec(func() {
|
||||||
|
if _, err = b.waitStatus(func(st uint16) bool {
|
||||||
|
return st != bcmStInProgress && st != bcmStBusy
|
||||||
|
}); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i, p := range params {
|
||||||
|
if err = b.mdioWrite(bcmMMDVendor, bcmRegData1+uint16(i), p); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = b.mdioWrite(bcmMMDVendor, bcmRegCmd, code); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var st uint16
|
||||||
|
if st, err = b.waitStatus(func(st uint16) bool {
|
||||||
|
return st == bcmStPass || st == bcmStError
|
||||||
|
}); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if st == bcmStError {
|
||||||
|
err = fmt.Errorf("%s: command %#04x returned ERROR", b.ifname, code)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(params) > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range data {
|
||||||
|
if data[i], err = b.mdioRead(bcmMMDVendor, bcmRegData1+uint16(i)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bcm) identify() (ident string, err error) {
|
||||||
|
b.exec(func() {
|
||||||
|
var hi, lo uint16
|
||||||
|
if hi, err = b.mdioRead(1, 2); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if lo, err = b.mdioRead(1, 3); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hi != bcmPHYIDHi || lo != bcmPHYIDLo {
|
||||||
|
err = fmt.Errorf("%s: PHY ID %#04x:%#04x, want %#04x:%#04x",
|
||||||
|
b.ifname, hi, lo, bcmPHYIDHi, bcmPHYIDLo)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var sn []byte
|
||||||
|
if sn, err = b.eeprom(68, 16); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ident = "BCM84891L sn " + strings.TrimSpace(string(sn))
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bcm) forceEEEOff() error {
|
||||||
|
_, err := b.command(bcmCmdSetEEEMode, 0x0000, 0x0000, 0x7A12, 0x0480, 0x0000)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bcm) forceJumbo() error {
|
||||||
|
_, err := b.command(bcmCmdSetJumbo, 1, 0, 0, 0, 0)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left to AN, master/slave is a per-training lottery and each training's DSP
|
||||||
|
// convergence moves per-pair SNR by up to ~3.6 dB; pinned roles at least keep
|
||||||
|
// every session measured under identical conditions.
|
||||||
|
func (b *bcm) forceRole(master bool) (err error) {
|
||||||
|
b.exec(func() {
|
||||||
|
var v uint16
|
||||||
|
if v, err = b.mdioRead(7, 32); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v |= 0x8000
|
||||||
|
if master {
|
||||||
|
v |= 0x4000
|
||||||
|
} else {
|
||||||
|
v &^= 0x4000
|
||||||
|
}
|
||||||
|
err = b.mdioWrite(7, 32, v)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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) cableDiag() (res ecdResult, err error) {
|
||||||
|
b.exec(func() {
|
||||||
|
var ctrl uint16
|
||||||
|
if ctrl, err = b.mdioRead(bcmMMDVendor, bcmRegECDCtrl); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = b.mdioWrite(bcmMMDVendor, bcmRegECDCtrl, ctrl&^0xF400|0x8400); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(ecdDeadline)
|
||||||
|
for {
|
||||||
|
if ctrl, err = b.mdioRead(bcmMMDVendor, bcmRegECDCtrl); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ctrl&0x0800 == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
err = fmt.Errorf("%s: cable diag still busy after %s", b.ifname, ecdDeadline)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(ecdPoll)
|
||||||
|
}
|
||||||
|
b.window()
|
||||||
|
var v uint16
|
||||||
|
if v, err = b.mdioRead(1, bcmRegECDResult); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range res.verdicts {
|
||||||
|
res.verdicts[i] = int(v>>(4*i)) & 0xF
|
||||||
|
if res.verdicts[i] > pairXtalk {
|
||||||
|
panic(fmt.Sprintf("%s: ghost ECD verdict %#04x", b.ifname, v))
|
||||||
|
}
|
||||||
|
var m uint16
|
||||||
|
if m, err = b.mdioRead(1, bcmRegECDLen+uint16(i)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.metres[i] = int(m)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
+420
@@ -0,0 +1,420 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
const wireOverhead = 24
|
||||||
|
|
||||||
|
type direction struct {
|
||||||
|
specs []*frameSpec
|
||||||
|
txStats []*txStats
|
||||||
|
rxStats []*rxStats
|
||||||
|
streams []lossWindow
|
||||||
|
txFDs []int
|
||||||
|
rxFDs []int
|
||||||
|
statFD int
|
||||||
|
|
||||||
|
// While a cable measure runs, every failure counter in this direction is
|
||||||
|
// suppressed at its source rather than counted, hidden and reverted.
|
||||||
|
measuring *atomic.Bool
|
||||||
|
|
||||||
|
// 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:
|
||||||
|
// sampleDrops consumes what it reads, so a second caller would see a gap.
|
||||||
|
mu sync.Mutex
|
||||||
|
win *rateWindow
|
||||||
|
drops uint64
|
||||||
|
base counterSet
|
||||||
|
|
||||||
|
// The smeared receive bucket the display draws its rate from, refreshed by
|
||||||
|
// the sampler because the buckets are keyed by the shared read clock and
|
||||||
|
// staleness has to be judged against the wall.
|
||||||
|
rateFrames uint64
|
||||||
|
rateBytes uint64
|
||||||
|
epoch int64
|
||||||
|
epochAt time.Time
|
||||||
|
|
||||||
|
// The settled window: each completed bucket enters once, donates its
|
||||||
|
// excess backward once, and leaves for display once no later bucket can
|
||||||
|
// still refill it. Recomputing the smear from the raw ring every sample
|
||||||
|
// instead would show each excess twice — once as the donation and again,
|
||||||
|
// unspent, when its bucket reaches the display slot of a later window.
|
||||||
|
smFrames [smearWindow]uint64
|
||||||
|
smBytes [smearWindow]uint64
|
||||||
|
smLen int
|
||||||
|
smNext int64
|
||||||
|
|
||||||
|
nic atomic.Uint64
|
||||||
|
poller *nicPoller
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything the display reads, taken at one instant, so a pair of these
|
||||||
|
// describes both the rates and the errors over the span between them.
|
||||||
|
type counterSet struct {
|
||||||
|
t time.Time
|
||||||
|
s sample
|
||||||
|
drops uint64
|
||||||
|
nic uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *direction) capture() counterSet {
|
||||||
|
d.sampleDrops()
|
||||||
|
s := d.snapshot()
|
||||||
|
return counterSet{t: time.Now(), s: s, drops: d.drops, nic: d.nic.Load()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What someone testing a cable is asking, rather than how each failure happened
|
||||||
|
// to be noticed.
|
||||||
|
type errs struct {
|
||||||
|
lost uint64
|
||||||
|
corrupt uint64
|
||||||
|
link uint64
|
||||||
|
internal uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e errs) add(o errs) errs {
|
||||||
|
return errs{
|
||||||
|
lost: e.lost + o.lost, corrupt: e.corrupt + o.corrupt,
|
||||||
|
link: e.link + o.link, internal: e.internal + o.internal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A ring of one bucket per drawn frame, spanning rateWindowSpan. Rates come
|
||||||
|
// from the gap between adjacent buckets and errors from the ends of the ring,
|
||||||
|
// so both slide forward every frame instead of stepping once a second.
|
||||||
|
type rateWindow struct {
|
||||||
|
buf []counterSet
|
||||||
|
idx int
|
||||||
|
filled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRateWindow(n int) *rateWindow {
|
||||||
|
return &rateWindow{buf: make([]counterSet, n)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *rateWindow) push(c counterSet) {
|
||||||
|
w.buf[w.idx] = c
|
||||||
|
w.idx++
|
||||||
|
if w.idx == len(w.buf) {
|
||||||
|
w.idx = 0
|
||||||
|
w.filled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *rateWindow) count() int {
|
||||||
|
if w.filled {
|
||||||
|
return len(w.buf)
|
||||||
|
}
|
||||||
|
return w.idx
|
||||||
|
}
|
||||||
|
|
||||||
|
// Indexed oldest first, so a partly filled ring reads the same as a full one.
|
||||||
|
func (w *rateWindow) at(i int) counterSet {
|
||||||
|
if w.filled {
|
||||||
|
i += w.idx
|
||||||
|
}
|
||||||
|
return w.buf[i%len(w.buf)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// How long the frontier may sit still before the wire is taken to have gone
|
||||||
|
// quiet. It only advances when frames arrive on every stream, so a frozen
|
||||||
|
// frontier means a stream has stopped delivering rather than an unchanged rate.
|
||||||
|
const rateStale = 100 * time.Millisecond
|
||||||
|
|
||||||
|
// An epoch is only reachable once a worker drains frames into it, so each
|
||||||
|
// stream's newest epoch is a frontier: everything that queue has been read
|
||||||
|
// through. Epochs behind the lowest frontier — the leader's would claim
|
||||||
|
// buckets the stragglers are still filling — are closed to further commits,
|
||||||
|
// so each is settled into the window exactly once. The display takes the
|
||||||
|
// window's oldest bucket, the one no later bucket can still refill, so the
|
||||||
|
// headline runs one window behind real time.
|
||||||
|
func (d *direction) readRateBucket(now time.Time) {
|
||||||
|
newest := int64(math.MaxInt64)
|
||||||
|
for _, r := range d.rxStats {
|
||||||
|
if e := r.newest.Load(); e < newest {
|
||||||
|
newest = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if newest > d.epoch {
|
||||||
|
d.epoch, d.epochAt = newest, now
|
||||||
|
}
|
||||||
|
if d.smNext == 0 && d.epoch > 1 {
|
||||||
|
d.smNext = d.epoch - 1
|
||||||
|
}
|
||||||
|
for e := d.smNext; e > 0 && e < d.epoch; e++ {
|
||||||
|
d.settle(e)
|
||||||
|
d.smNext = e + 1
|
||||||
|
}
|
||||||
|
d.rateFrames, d.rateBytes = 0, 0
|
||||||
|
if d.smLen == 0 || now.Sub(d.epochAt) > rateStale {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.rateFrames, d.rateBytes = d.smFrames[0], d.smBytes[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *direction) settle(e int64) {
|
||||||
|
if d.smLen == smearWindow {
|
||||||
|
copy(d.smFrames[:], d.smFrames[1:])
|
||||||
|
copy(d.smBytes[:], d.smBytes[1:])
|
||||||
|
d.smLen--
|
||||||
|
}
|
||||||
|
var f, b uint64
|
||||||
|
for _, r := range d.rxStats {
|
||||||
|
rf, rb := r.bucket(e)
|
||||||
|
f += rf
|
||||||
|
b += rb
|
||||||
|
}
|
||||||
|
d.smFrames[d.smLen], d.smBytes[d.smLen] = f, b
|
||||||
|
d.smLen++
|
||||||
|
fillBack(d.smFrames[:d.smLen], d.smBytes[:d.smLen])
|
||||||
|
}
|
||||||
|
|
||||||
|
type sample struct {
|
||||||
|
rxFrames, rxBytes uint64
|
||||||
|
lost, late uint64
|
||||||
|
crcErr, badMagic uint64
|
||||||
|
badHdr uint64
|
||||||
|
badLen uint64
|
||||||
|
txErrs uint64
|
||||||
|
rxErrs uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *direction) snapshot() sample {
|
||||||
|
var s sample
|
||||||
|
for _, t := range d.txStats {
|
||||||
|
s.txErrs += t.errs.Load()
|
||||||
|
}
|
||||||
|
for _, r := range d.rxStats {
|
||||||
|
s.rxFrames += r.frames.Load()
|
||||||
|
s.rxBytes += r.bytes.Load()
|
||||||
|
s.crcErr += r.crcErr.Load()
|
||||||
|
s.badMagic += r.badMagic.Load()
|
||||||
|
s.badHdr += r.badHdr.Load()
|
||||||
|
s.badLen += r.badLen.Load()
|
||||||
|
s.rxErrs += r.rxErrs.Load()
|
||||||
|
}
|
||||||
|
for i := range d.streams {
|
||||||
|
s.lost += d.streams[i].lost.Load()
|
||||||
|
s.late += d.streams[i].late.Load()
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counters keep climbing in the workers, so resetting just moves the origin
|
||||||
|
// everything is measured from. Rates and the rolling error window are about now
|
||||||
|
// rather than since the reset, so they keep running; the origin goes into the
|
||||||
|
// ring so the newest bucket never sits behind it.
|
||||||
|
func (d *direction) reset() {
|
||||||
|
d.mu.Lock()
|
||||||
|
d.base = d.capture()
|
||||||
|
d.win.push(d.base)
|
||||||
|
d.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The socket statistic is read-and-clear, so it is always consumed; a drop
|
||||||
|
// during a cable measure is the blip's and is discarded at this source.
|
||||||
|
func (d *direction) sampleDrops() {
|
||||||
|
for _, fd := range d.rxFDs {
|
||||||
|
n := packetDrops(fd)
|
||||||
|
if !d.measuring.Load() {
|
||||||
|
d.drops += n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func gbps(bytes, frames uint64, secs float64) float64 {
|
||||||
|
return float64((bytes+frames*wireOverhead)*8) / secs / 1e9
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared by the console table and the framebuffer so both show the same
|
||||||
|
// figures.
|
||||||
|
type view struct {
|
||||||
|
rxPPS float64
|
||||||
|
rxGbps float64
|
||||||
|
rxFrames, rxBytes uint64
|
||||||
|
since errs
|
||||||
|
window errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func errsBetween(b, n counterSet) errs {
|
||||||
|
return errs{
|
||||||
|
lost: n.s.lost - b.s.lost,
|
||||||
|
// Four ways of noticing one thing: a payload that does not match its
|
||||||
|
// checksum, a header that does not match its own, a header that is not
|
||||||
|
// ours, and a length that cannot be.
|
||||||
|
corrupt: (n.s.crcErr - b.s.crcErr) + (n.s.badHdr - b.s.badHdr) +
|
||||||
|
(n.s.badMagic - b.s.badMagic) + (n.s.badLen - b.s.badLen),
|
||||||
|
// What the hardware reported. Nothing the host declined to send is here,
|
||||||
|
// so this one going red means the cable.
|
||||||
|
link: (n.nic - b.nic) + (n.s.rxErrs - b.s.rxErrs),
|
||||||
|
// Ours rather than the cable's. A late frame is unreachable while each
|
||||||
|
// stream has a flow rule to its own queue, which is exactly why it is
|
||||||
|
// worth counting.
|
||||||
|
internal: (n.drops - b.drops) + (n.s.late - b.s.late) +
|
||||||
|
(n.s.txErrs - b.s.txErrs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
since: errsBetween(d.base, now),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func totalView(views []view) view {
|
||||||
|
var t view
|
||||||
|
for _, v := range views {
|
||||||
|
t.rxPPS += v.rxPPS
|
||||||
|
t.rxGbps += v.rxGbps
|
||||||
|
t.rxFrames += v.rxFrames
|
||||||
|
t.rxBytes += v.rxBytes
|
||||||
|
t.since = t.since.add(v.since)
|
||||||
|
t.window = t.window.add(v.window)
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *direction) sample() {
|
||||||
|
d.mu.Lock()
|
||||||
|
d.win.push(d.capture())
|
||||||
|
d.readRateBucket(time.Now())
|
||||||
|
d.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draws what the sampler last put in the ring rather than reading the counters
|
||||||
|
// again, so the display never participates in the measurement.
|
||||||
|
func (d *direction) displayView() view {
|
||||||
|
d.mu.Lock()
|
||||||
|
n := d.win.count()
|
||||||
|
if n == 0 {
|
||||||
|
d.mu.Unlock()
|
||||||
|
return view{}
|
||||||
|
}
|
||||||
|
v := d.counters(d.win.at(n - 1))
|
||||||
|
if n >= 2 {
|
||||||
|
v.window = errsBetween(d.win.at(0), d.win.at(n-1))
|
||||||
|
}
|
||||||
|
v.rxPPS = float64(d.rateFrames) / rateBucketSecs
|
||||||
|
v.rxGbps = gbps(d.rateBytes, d.rateFrames, rateBucketSecs)
|
||||||
|
d.mu.Unlock()
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whatever the interfaces counted before now is not ours, and no interval has
|
||||||
|
// elapsed yet, so every baseline starts here and nothing is reported until the
|
||||||
|
// first one completes.
|
||||||
|
func (d *direction) primeCounters() {
|
||||||
|
d.poller.prime()
|
||||||
|
d.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDirection(label string, tx, rx endpoint, measuring *atomic.Bool) (*direction, error) {
|
||||||
|
// Built before the windows, since each window judges sequence numbers against
|
||||||
|
// the frontier its own sender publishes.
|
||||||
|
txs := make([]*txStats, numStreams)
|
||||||
|
for i := range txs {
|
||||||
|
txs[i] = &txStats{}
|
||||||
|
}
|
||||||
|
d := &direction{
|
||||||
|
txStats: txs,
|
||||||
|
streams: newLossWindows(txs),
|
||||||
|
measuring: measuring,
|
||||||
|
}
|
||||||
|
// 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.
|
||||||
|
statFD, err := unix.Socket(unix.AF_INET, unix.SOCK_DGRAM, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s stats socket: %w", label, err)
|
||||||
|
}
|
||||||
|
d.statFD = statFD
|
||||||
|
|
||||||
|
d.poller, err = newNICPoller(statFD, tx.name, rx.name, &d.nic, measuring)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", label, err)
|
||||||
|
}
|
||||||
|
d.win = newRateWindow(int(rateWindowSpan/sampleInterval) + 1)
|
||||||
|
|
||||||
|
for i := 0; i < numStreams; i++ {
|
||||||
|
et := uint16(etherBase + i)
|
||||||
|
d.specs = append(d.specs, newFrameSpec(rx.mac, tx.mac, et, frameSizes))
|
||||||
|
|
||||||
|
fd, err := openTxSocket(tx.idx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s tx socket: %w", label, err)
|
||||||
|
}
|
||||||
|
d.txFDs = append(d.txFDs, fd)
|
||||||
|
|
||||||
|
fd, err = openRxSocket(rx.idx, et)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s rx socket for 0x%04x: %w", label, et, err)
|
||||||
|
}
|
||||||
|
d.rxFDs = append(d.rxFDs, fd)
|
||||||
|
d.rxStats = append(d.rxStats, &rxStats{})
|
||||||
|
}
|
||||||
|
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *direction) start(wg *sync.WaitGroup, done *atomic.Bool, rxReady *sync.WaitGroup, startTx <-chan struct{}) {
|
||||||
|
for i, fd := range d.txFDs {
|
||||||
|
w := &txWorker{
|
||||||
|
fd: fd,
|
||||||
|
stream: uint16(i),
|
||||||
|
spec: d.specs[i],
|
||||||
|
batch: batchSize,
|
||||||
|
stats: d.txStats[i],
|
||||||
|
measuring: d.measuring,
|
||||||
|
startTx: startTx,
|
||||||
|
}
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
defer holdPanic()
|
||||||
|
w.run(done)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
for i, fd := range d.rxFDs {
|
||||||
|
w := &rxWorker{
|
||||||
|
fd: fd,
|
||||||
|
batch: batchSize,
|
||||||
|
stream: uint16(i),
|
||||||
|
spec: d.specs[i],
|
||||||
|
stats: d.rxStats[i],
|
||||||
|
loss: &d.streams[i],
|
||||||
|
measuring: d.measuring,
|
||||||
|
ready: rxReady,
|
||||||
|
}
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
defer holdPanic()
|
||||||
|
w.run(done)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
defer holdPanic()
|
||||||
|
d.poller.run(done, startTx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *direction) close() {
|
||||||
|
for _, fd := range d.txFDs {
|
||||||
|
unix.Close(fd)
|
||||||
|
}
|
||||||
|
for _, fd := range d.rxFDs {
|
||||||
|
unix.Close(fd)
|
||||||
|
}
|
||||||
|
unix.Close(d.statFD)
|
||||||
|
}
|
||||||
+1
-1
@@ -46,5 +46,5 @@ In `~/work/`, ready to fold into the repo's `kernel/`:
|
|||||||
|
|
||||||
## Every boot
|
## Every boot
|
||||||
|
|
||||||
1. `./load-ixgbe` — until then the FS port has no netdev (stock driver, error -95) and `driverPair("ixgbe")` cannot find 2 interfaces. Verify `sff_i2c` present for both ports.
|
1. `./load-ixgbe` — until then the FS port has no netdev (stock driver, error -95) and `driverPair("ixgbe")` cannot find 2 interfaces. cabletest's BOOT check names the condition (`patched ixgbe` fails when any bound port lacks `sff_i2c`).
|
||||||
2. Re-apply host tuning (hardware.md) before trusting results.
|
2. Re-apply host tuning (hardware.md) before trusting results.
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
@@ -89,6 +91,32 @@ func mountFilesystems() []checkResult {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The sff_i2c debugfs node is the patched driver's signature. Checked up front
|
||||||
|
// because stock ixgbe also refuses the FS module's EEPROM and leaves its port
|
||||||
|
// with no netdev, so the missing patch would otherwise surface later as a
|
||||||
|
// baffling "want 2 ixgbe interfaces, found 1".
|
||||||
|
func checkPatchedIxgbe() checkResult {
|
||||||
|
res := checkResult{item: "patched ixgbe"}
|
||||||
|
devs, err := filepath.Glob("/sys/kernel/debug/ixgbe/*")
|
||||||
|
if err != nil {
|
||||||
|
res.err = err
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
if len(devs) == 0 {
|
||||||
|
res.err = fmt.Errorf("no ixgbe devices in debugfs; run ./load-ixgbe")
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
for _, d := range devs {
|
||||||
|
if _, err := os.Stat(d + "/sff_i2c"); err != nil {
|
||||||
|
res.err = fmt.Errorf("%s has no sff_i2c: stock ixgbe loaded; run ./load-ixgbe",
|
||||||
|
filepath.Base(d))
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.state = fmt.Sprintf("sff_i2c on %d devices", len(devs))
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
touchTimeout = 30 * time.Second
|
touchTimeout = 30 * time.Second
|
||||||
touchPoll = 100 * time.Millisecond
|
touchPoll = 100 * time.Millisecond
|
||||||
@@ -123,9 +151,10 @@ func waitForTouchscreen() checkResult {
|
|||||||
|
|
||||||
func bootstrap() []checkResult {
|
func bootstrap() []checkResult {
|
||||||
out := mountFilesystems()
|
out := mountFilesystems()
|
||||||
// /dev/input/event* only exists once devtmpfs is mounted above.
|
// debugfs and /dev/input/event* only exist once the mounts above are in.
|
||||||
if out[len(out)-1].err != nil {
|
if out[len(out)-1].err != nil {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
out = append(out, checkPatchedIxgbe())
|
||||||
return append(out, waitForTouchscreen())
|
return append(out, waitForTouchscreen())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -127,9 +128,9 @@ func watchTouch(w, h int) (*touchState, error) {
|
|||||||
panic(fmt.Sprintf("reading touchscreen events: %v", err))
|
panic(fmt.Sprintf("reading touchscreen events: %v", err))
|
||||||
}
|
}
|
||||||
for o := 0; o+sizeofInputEvent <= n; o += sizeofInputEvent {
|
for o := 0; o+sizeofInputEvent <= n; o += sizeofInputEvent {
|
||||||
typ := *(*uint16)(unsafe.Pointer(&buf[o+16]))
|
typ := binary.NativeEndian.Uint16(buf[o+16:])
|
||||||
code := *(*uint16)(unsafe.Pointer(&buf[o+18]))
|
code := binary.NativeEndian.Uint16(buf[o+18:])
|
||||||
val := *(*int32)(unsafe.Pointer(&buf[o+20]))
|
val := int32(binary.NativeEndian.Uint32(buf[o+20:]))
|
||||||
switch typ {
|
switch typ {
|
||||||
case evAbs:
|
case evAbs:
|
||||||
switch code {
|
switch code {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@@ -15,8 +14,6 @@ import (
|
|||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
)
|
)
|
||||||
|
|
||||||
const wireOverhead = 24
|
|
||||||
|
|
||||||
type endpoint struct {
|
type endpoint struct {
|
||||||
name string
|
name string
|
||||||
tag string
|
tag string
|
||||||
@@ -30,180 +27,6 @@ func (e endpoint) macString() string {
|
|||||||
e.mac[0], e.mac[1], e.mac[2], e.mac[3], e.mac[4], e.mac[5])
|
e.mac[0], e.mac[1], e.mac[2], e.mac[3], e.mac[4], e.mac[5])
|
||||||
}
|
}
|
||||||
|
|
||||||
type direction struct {
|
|
||||||
specs []*frameSpec
|
|
||||||
txStats []*txStats
|
|
||||||
rxStats []*rxStats
|
|
||||||
streams []lossWindow
|
|
||||||
txFDs []int
|
|
||||||
rxFDs []int
|
|
||||||
statFD int
|
|
||||||
|
|
||||||
// While a cable measure runs, every failure counter in this direction is
|
|
||||||
// suppressed at its source rather than counted, hidden and reverted.
|
|
||||||
measuring *atomic.Bool
|
|
||||||
|
|
||||||
// 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:
|
|
||||||
// sampleDrops consumes what it reads, so a second caller would see a gap.
|
|
||||||
mu sync.Mutex
|
|
||||||
win *rateWindow
|
|
||||||
drops uint64
|
|
||||||
base counterSet
|
|
||||||
|
|
||||||
// The smeared receive bucket the display draws its rate from, refreshed by
|
|
||||||
// the sampler because the buckets are keyed by the shared read clock and
|
|
||||||
// staleness has to be judged against the wall.
|
|
||||||
rateFrames uint64
|
|
||||||
rateBytes uint64
|
|
||||||
epoch int64
|
|
||||||
epochAt time.Time
|
|
||||||
|
|
||||||
// The settled window: each completed bucket enters once, donates its
|
|
||||||
// excess backward once, and leaves for display once no later bucket can
|
|
||||||
// still refill it. Recomputing the smear from the raw ring every sample
|
|
||||||
// instead would show each excess twice — once as the donation and again,
|
|
||||||
// unspent, when its bucket reaches the display slot of a later window.
|
|
||||||
smFrames [smearWindow]uint64
|
|
||||||
smBytes [smearWindow]uint64
|
|
||||||
smLen int
|
|
||||||
smNext int64
|
|
||||||
|
|
||||||
nic atomic.Uint64
|
|
||||||
poller *nicPoller
|
|
||||||
}
|
|
||||||
|
|
||||||
// Everything the display reads, taken at one instant, so a pair of these
|
|
||||||
// describes both the rates and the errors over the span between them.
|
|
||||||
type counterSet struct {
|
|
||||||
t time.Time
|
|
||||||
s sample
|
|
||||||
drops uint64
|
|
||||||
nic uint64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *direction) capture() counterSet {
|
|
||||||
d.sampleDrops()
|
|
||||||
s := d.snapshot()
|
|
||||||
return counterSet{t: time.Now(), s: s, drops: d.drops, nic: d.nic.Load()}
|
|
||||||
}
|
|
||||||
|
|
||||||
// What someone testing a cable is asking, rather than how each failure happened
|
|
||||||
// to be noticed.
|
|
||||||
type errs struct {
|
|
||||||
lost uint64
|
|
||||||
corrupt uint64
|
|
||||||
link uint64
|
|
||||||
internal uint64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e errs) add(o errs) errs {
|
|
||||||
return errs{
|
|
||||||
lost: e.lost + o.lost, corrupt: e.corrupt + o.corrupt,
|
|
||||||
link: e.link + o.link, internal: e.internal + o.internal,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A ring of one bucket per drawn frame, spanning rateWindowSpan. Rates come
|
|
||||||
// from the gap between adjacent buckets and errors from the ends of the ring,
|
|
||||||
// so both slide forward every frame instead of stepping once a second.
|
|
||||||
type rateWindow struct {
|
|
||||||
buf []counterSet
|
|
||||||
idx int
|
|
||||||
filled bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func newRateWindow(n int) *rateWindow {
|
|
||||||
return &rateWindow{buf: make([]counterSet, n)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *rateWindow) push(c counterSet) {
|
|
||||||
w.buf[w.idx] = c
|
|
||||||
w.idx++
|
|
||||||
if w.idx == len(w.buf) {
|
|
||||||
w.idx = 0
|
|
||||||
w.filled = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *rateWindow) count() int {
|
|
||||||
if w.filled {
|
|
||||||
return len(w.buf)
|
|
||||||
}
|
|
||||||
return w.idx
|
|
||||||
}
|
|
||||||
|
|
||||||
// Indexed oldest first, so a partly filled ring reads the same as a full one.
|
|
||||||
func (w *rateWindow) at(i int) counterSet {
|
|
||||||
if w.filled {
|
|
||||||
i += w.idx
|
|
||||||
}
|
|
||||||
return w.buf[i%len(w.buf)]
|
|
||||||
}
|
|
||||||
|
|
||||||
// How long the frontier may sit still before the wire is taken to have gone
|
|
||||||
// quiet. It only advances when frames arrive on every stream, so a frozen
|
|
||||||
// frontier means a stream has stopped delivering rather than an unchanged rate.
|
|
||||||
const rateStale = 100 * time.Millisecond
|
|
||||||
|
|
||||||
// An epoch is only reachable once a worker drains frames into it, so each
|
|
||||||
// stream's newest epoch is a frontier: everything that queue has been read
|
|
||||||
// through. Epochs behind the lowest frontier — the leader's would claim
|
|
||||||
// buckets the stragglers are still filling — are closed to further commits,
|
|
||||||
// so each is settled into the window exactly once. The display takes the
|
|
||||||
// window's oldest bucket, the one no later bucket can still refill, so the
|
|
||||||
// headline runs one window behind real time.
|
|
||||||
func (d *direction) readRateBucket(now time.Time) {
|
|
||||||
newest := int64(math.MaxInt64)
|
|
||||||
for _, r := range d.rxStats {
|
|
||||||
if e := r.newest.Load(); e < newest {
|
|
||||||
newest = e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if newest > d.epoch {
|
|
||||||
d.epoch, d.epochAt = newest, now
|
|
||||||
}
|
|
||||||
if d.smNext == 0 && d.epoch > 1 {
|
|
||||||
d.smNext = d.epoch - 1
|
|
||||||
}
|
|
||||||
for e := d.smNext; e > 0 && e < d.epoch; e++ {
|
|
||||||
d.settle(e)
|
|
||||||
d.smNext = e + 1
|
|
||||||
}
|
|
||||||
d.rateFrames, d.rateBytes = 0, 0
|
|
||||||
if d.smLen == 0 || now.Sub(d.epochAt) > rateStale {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
d.rateFrames, d.rateBytes = d.smFrames[0], d.smBytes[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *direction) settle(e int64) {
|
|
||||||
if d.smLen == smearWindow {
|
|
||||||
copy(d.smFrames[:], d.smFrames[1:])
|
|
||||||
copy(d.smBytes[:], d.smBytes[1:])
|
|
||||||
d.smLen--
|
|
||||||
}
|
|
||||||
var f, b uint64
|
|
||||||
for _, r := range d.rxStats {
|
|
||||||
rf, rb := r.bucket(e)
|
|
||||||
f += rf
|
|
||||||
b += rb
|
|
||||||
}
|
|
||||||
d.smFrames[d.smLen], d.smBytes[d.smLen] = f, b
|
|
||||||
d.smLen++
|
|
||||||
fillBack(d.smFrames[:d.smLen], d.smBytes[:d.smLen])
|
|
||||||
}
|
|
||||||
|
|
||||||
type sample struct {
|
|
||||||
rxFrames, rxBytes uint64
|
|
||||||
lost, late uint64
|
|
||||||
crcErr, badMagic uint64
|
|
||||||
badHdr uint64
|
|
||||||
badLen uint64
|
|
||||||
txErrs uint64
|
|
||||||
rxErrs uint64
|
|
||||||
}
|
|
||||||
|
|
||||||
func lookupEndpoint(name string) (endpoint, error) {
|
func lookupEndpoint(name string) (endpoint, error) {
|
||||||
ifi, err := net.InterfaceByName(name)
|
ifi, err := net.InterfaceByName(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -217,38 +40,6 @@ func lookupEndpoint(name string) (endpoint, error) {
|
|||||||
return endpoint{name: name, idx: ifi.Index, mac: mac, mtu: ifi.MTU}, nil
|
return endpoint{name: name, idx: ifi.Index, mac: mac, mtu: ifi.MTU}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *direction) snapshot() sample {
|
|
||||||
var s sample
|
|
||||||
for _, t := range d.txStats {
|
|
||||||
s.txErrs += t.errs.Load()
|
|
||||||
}
|
|
||||||
for _, r := range d.rxStats {
|
|
||||||
s.rxFrames += r.frames.Load()
|
|
||||||
s.rxBytes += r.bytes.Load()
|
|
||||||
s.crcErr += r.crcErr.Load()
|
|
||||||
s.badMagic += r.badMagic.Load()
|
|
||||||
s.badHdr += r.badHdr.Load()
|
|
||||||
s.badLen += r.badLen.Load()
|
|
||||||
s.rxErrs += r.rxErrs.Load()
|
|
||||||
}
|
|
||||||
for i := range d.streams {
|
|
||||||
s.lost += d.streams[i].lost.Load()
|
|
||||||
s.late += d.streams[i].late.Load()
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// Counters keep climbing in the workers, so resetting just moves the origin
|
|
||||||
// everything is measured from. Rates and the rolling error window are about now
|
|
||||||
// rather than since the reset, so they keep running; the origin goes into the
|
|
||||||
// ring so the newest bucket never sits behind it.
|
|
||||||
func (d *direction) reset() {
|
|
||||||
d.mu.Lock()
|
|
||||||
d.base = d.capture()
|
|
||||||
d.win.push(d.base)
|
|
||||||
d.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns the new start time, so the uptime shown alongside the totals counts
|
// Returns the new start time, so the uptime shown alongside the totals counts
|
||||||
// from the reset rather than from launch; the elapsed clock restarting is the
|
// from the reset rather than from launch; the elapsed clock restarting is the
|
||||||
// visible mark of the re-baseline.
|
// visible mark of the re-baseline.
|
||||||
@@ -262,19 +53,11 @@ func resetAll(dirs []*direction, mods []*phyModule) time.Time {
|
|||||||
return time.Now()
|
return time.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
// The socket statistic is read-and-clear, so it is always consumed; a drop
|
// Nothing to hold or revert here: failures during a measure were never
|
||||||
// during a cable measure is the blip's and is discarded at this source.
|
// counted, so the view is always the counters as they stand.
|
||||||
func (d *direction) sampleDrops() {
|
func phyView(diag *cableDiag, modules []*phyModule) (phyDisplay, bool) {
|
||||||
for _, fd := range d.rxFDs {
|
info, measuring := diag.snapshot()
|
||||||
n := packetDrops(fd)
|
return phyDisplayFrom(info, measuring, modules[0].view(), modules[1].view()), measuring
|
||||||
if !d.measuring.Load() {
|
|
||||||
d.drops += n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func gbps(bytes, frames uint64, secs float64) float64 {
|
|
||||||
return float64((bytes+frames*wireOverhead)*8) / secs / 1e9
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var intervalCols = []colSpec{
|
var intervalCols = []colSpec{
|
||||||
@@ -298,89 +81,6 @@ var intervalCols = []colSpec{
|
|||||||
{group: "OVERALL", title: "internal", width: 9, right: true},
|
{group: "OVERALL", title: "internal", width: 9, right: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared by the console table and the framebuffer so both show the same
|
|
||||||
// figures.
|
|
||||||
type view struct {
|
|
||||||
rxPPS float64
|
|
||||||
rxGbps float64
|
|
||||||
rxFrames, rxBytes uint64
|
|
||||||
since errs
|
|
||||||
window errs
|
|
||||||
}
|
|
||||||
|
|
||||||
func errsBetween(b, n counterSet) errs {
|
|
||||||
return errs{
|
|
||||||
lost: n.s.lost - b.s.lost,
|
|
||||||
// Four ways of noticing one thing: a payload that does not match its
|
|
||||||
// checksum, a header that does not match its own, a header that is not
|
|
||||||
// ours, and a length that cannot be.
|
|
||||||
corrupt: (n.s.crcErr - b.s.crcErr) + (n.s.badHdr - b.s.badHdr) +
|
|
||||||
(n.s.badMagic - b.s.badMagic) + (n.s.badLen - b.s.badLen),
|
|
||||||
// What the hardware reported. Nothing the host declined to send is here,
|
|
||||||
// so this one going red means the cable.
|
|
||||||
link: (n.nic - b.nic) + (n.s.rxErrs - b.s.rxErrs),
|
|
||||||
// Ours rather than the cable's. A late frame is unreachable while each
|
|
||||||
// stream has a flow rule to its own queue, which is exactly why it is
|
|
||||||
// worth counting.
|
|
||||||
internal: (n.drops - b.drops) + (n.s.late - b.s.late) +
|
|
||||||
(n.s.txErrs - b.s.txErrs),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
since: errsBetween(d.base, now),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nothing to hold or revert here: failures during a measure were never
|
|
||||||
// counted, so the view is always the counters as they stand.
|
|
||||||
func phyView(diag *cableDiag, modules []*phyModule) (phyDisplay, bool) {
|
|
||||||
info, measuring := diag.snapshot()
|
|
||||||
return phyDisplayFrom(info, measuring, modules[0].view(), modules[1].view()), measuring
|
|
||||||
}
|
|
||||||
|
|
||||||
func totalView(views []view) view {
|
|
||||||
var t view
|
|
||||||
for _, v := range views {
|
|
||||||
t.rxPPS += v.rxPPS
|
|
||||||
t.rxGbps += v.rxGbps
|
|
||||||
t.rxFrames += v.rxFrames
|
|
||||||
t.rxBytes += v.rxBytes
|
|
||||||
t.since = t.since.add(v.since)
|
|
||||||
t.window = t.window.add(v.window)
|
|
||||||
}
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *direction) sample() {
|
|
||||||
d.mu.Lock()
|
|
||||||
d.win.push(d.capture())
|
|
||||||
d.readRateBucket(time.Now())
|
|
||||||
d.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draws what the sampler last put in the ring rather than reading the counters
|
|
||||||
// again, so the display never participates in the measurement.
|
|
||||||
func (d *direction) displayView() view {
|
|
||||||
d.mu.Lock()
|
|
||||||
n := d.win.count()
|
|
||||||
if n == 0 {
|
|
||||||
d.mu.Unlock()
|
|
||||||
return view{}
|
|
||||||
}
|
|
||||||
v := d.counters(d.win.at(n - 1))
|
|
||||||
if n >= 2 {
|
|
||||||
v.window = errsBetween(d.win.at(0), d.win.at(n-1))
|
|
||||||
}
|
|
||||||
v.rxPPS = float64(d.rateFrames) / rateBucketSecs
|
|
||||||
v.rxGbps = gbps(d.rateBytes, d.rateFrames, rateBucketSecs)
|
|
||||||
d.mu.Unlock()
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
// The same figures the panel draws, in the same order: the last second as
|
// 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
|
// rates and error flags with the noise cable riding at the end of them, then
|
||||||
// everything since the reset.
|
// everything since the reset.
|
||||||
@@ -407,116 +107,6 @@ func totalRow(elapsed time.Duration, v view, target float64, phy phyDisplay, nv
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whatever the interfaces counted before now is not ours, and no interval has
|
|
||||||
// elapsed yet, so every baseline starts here and nothing is reported until the
|
|
||||||
// first one completes.
|
|
||||||
func (d *direction) primeCounters() {
|
|
||||||
d.poller.prime()
|
|
||||||
d.reset()
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildDirection(label string, tx, rx endpoint, measuring *atomic.Bool) (*direction, error) {
|
|
||||||
// Built before the windows, since each window judges sequence numbers against
|
|
||||||
// the frontier its own sender publishes.
|
|
||||||
txs := make([]*txStats, numStreams)
|
|
||||||
for i := range txs {
|
|
||||||
txs[i] = &txStats{}
|
|
||||||
}
|
|
||||||
d := &direction{
|
|
||||||
txStats: txs,
|
|
||||||
streams: newLossWindows(txs),
|
|
||||||
measuring: measuring,
|
|
||||||
}
|
|
||||||
// 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.
|
|
||||||
statFD, err := unix.Socket(unix.AF_INET, unix.SOCK_DGRAM, 0)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s stats socket: %w", label, err)
|
|
||||||
}
|
|
||||||
d.statFD = statFD
|
|
||||||
|
|
||||||
d.poller, err = newNICPoller(statFD, tx.name, rx.name, &d.nic, measuring)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s: %w", label, err)
|
|
||||||
}
|
|
||||||
d.win = newRateWindow(int(rateWindowSpan/sampleInterval) + 1)
|
|
||||||
|
|
||||||
for i := 0; i < numStreams; i++ {
|
|
||||||
et := uint16(etherBase + i)
|
|
||||||
d.specs = append(d.specs, newFrameSpec(rx.mac, tx.mac, et, frameSizes))
|
|
||||||
|
|
||||||
fd, err := openTxSocket(tx.idx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s tx socket: %w", label, err)
|
|
||||||
}
|
|
||||||
d.txFDs = append(d.txFDs, fd)
|
|
||||||
|
|
||||||
fd, err = openRxSocket(rx.idx, et)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s rx socket for 0x%04x: %w", label, et, err)
|
|
||||||
}
|
|
||||||
d.rxFDs = append(d.rxFDs, fd)
|
|
||||||
d.rxStats = append(d.rxStats, &rxStats{})
|
|
||||||
}
|
|
||||||
|
|
||||||
return d, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *direction) start(wg *sync.WaitGroup, done *atomic.Bool, rxReady *sync.WaitGroup, startTx <-chan struct{}) {
|
|
||||||
for i, fd := range d.txFDs {
|
|
||||||
w := &txWorker{
|
|
||||||
fd: fd,
|
|
||||||
stream: uint16(i),
|
|
||||||
spec: d.specs[i],
|
|
||||||
batch: batchSize,
|
|
||||||
stats: d.txStats[i],
|
|
||||||
measuring: d.measuring,
|
|
||||||
startTx: startTx,
|
|
||||||
}
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
defer holdPanic()
|
|
||||||
w.run(done)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
for i, fd := range d.rxFDs {
|
|
||||||
w := &rxWorker{
|
|
||||||
fd: fd,
|
|
||||||
batch: batchSize,
|
|
||||||
stream: uint16(i),
|
|
||||||
spec: d.specs[i],
|
|
||||||
stats: d.rxStats[i],
|
|
||||||
loss: &d.streams[i],
|
|
||||||
measuring: d.measuring,
|
|
||||||
ready: rxReady,
|
|
||||||
}
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
defer holdPanic()
|
|
||||||
w.run(done)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
defer holdPanic()
|
|
||||||
d.poller.run(done, startTx)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *direction) close() {
|
|
||||||
for _, fd := range d.txFDs {
|
|
||||||
unix.Close(fd)
|
|
||||||
}
|
|
||||||
for _, fd := range d.rxFDs {
|
|
||||||
unix.Close(fd)
|
|
||||||
}
|
|
||||||
unix.Close(d.statFD)
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
numStreams = 7
|
numStreams = 7
|
||||||
batchSize = 64
|
batchSize = 64
|
||||||
|
|||||||
@@ -2,58 +2,12 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/sys/unix"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
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
|
|
||||||
bcmCmdSetJumbo uint16 = 0x801C
|
|
||||||
|
|
||||||
bcmRegECDCtrl uint16 = 0x4006
|
|
||||||
bcmRegECDResult uint16 = 0xA896
|
|
||||||
bcmRegECDLen uint16 = 0xA897
|
|
||||||
|
|
||||||
bcmPHYIDHi = 0x3590
|
|
||||||
bcmPHYIDLo = 0x5081
|
|
||||||
|
|
||||||
bcmCmdResidentTemp uint16 = 0x0031
|
|
||||||
|
|
||||||
bcmReadDelayUs = 3000
|
|
||||||
bcmRetryDelayUs = 10000
|
|
||||||
|
|
||||||
bcmWindow = 3400 * time.Millisecond
|
|
||||||
bcmWindowFit = 100 * time.Millisecond
|
|
||||||
bcmFlipPoll = 10 * time.Millisecond
|
|
||||||
bcmFlipWait = 5 * time.Second
|
|
||||||
|
|
||||||
bcmStatusPoll = 100 * time.Millisecond
|
|
||||||
// Covers the handler's documented 2 s freeze during 10GBASE-T training.
|
|
||||||
bcmStatusTimeout = 3 * time.Second
|
|
||||||
|
|
||||||
ecdPoll = 200 * time.Millisecond
|
|
||||||
ecdDeadline = 50 * time.Second
|
|
||||||
|
|
||||||
pairIdentityMap = 0xE4
|
pairIdentityMap = 0xE4
|
||||||
|
|
||||||
fsVendorPN = "SFP-10G-T-100"
|
fsVendorPN = "SFP-10G-T-100"
|
||||||
@@ -71,121 +25,6 @@ var pairVerdicts = map[int]string{
|
|||||||
pairOK: "ok", pairOpen: "OPEN", pairShort: "SHORT", pairXtalk: "XTALK",
|
pairOK: "ok", pairOpen: "OPEN", pairShort: "SHORT", pairXtalk: "XTALK",
|
||||||
}
|
}
|
||||||
|
|
||||||
type sff struct {
|
|
||||||
ifname string
|
|
||||||
path string
|
|
||||||
|
|
||||||
// Every transport touch happens on the loop goroutine: requests execute
|
|
||||||
// one at a time, each admitted by the module type's own gate first.
|
|
||||||
reqs chan func()
|
|
||||||
admit func()
|
|
||||||
}
|
|
||||||
|
|
||||||
func openSFF(ifname string) (*sff, 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)
|
|
||||||
}
|
|
||||||
s := &sff{
|
|
||||||
ifname: ifname,
|
|
||||||
path: "/sys/kernel/debug/ixgbe/" + filepath.Base(devLink) + "/sff_i2c",
|
|
||||||
reqs: make(chan func()),
|
|
||||||
admit: func() {},
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(s.path); err != nil {
|
|
||||||
return nil, fmt.Errorf("%s: %w (patched ixgbe?)", ifname, err)
|
|
||||||
}
|
|
||||||
go s.loop()
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *sff) loop() {
|
|
||||||
defer holdPanic()
|
|
||||||
for fn := range s.reqs {
|
|
||||||
s.admit()
|
|
||||||
fn()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *sff) exec(fn func()) {
|
|
||||||
done := make(chan struct{})
|
|
||||||
s.reqs <- func() { fn(); close(done) }
|
|
||||||
<-done
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *sff) name() string { return s.ifname }
|
|
||||||
|
|
||||||
func (s *sff) op(cmd string) (string, error) {
|
|
||||||
fd, err := unix.Open(s.path, unix.O_RDWR, 0)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("%s: %w", s.path, err)
|
|
||||||
}
|
|
||||||
defer unix.Close(fd)
|
|
||||||
if _, err := unix.Write(fd, []byte(cmd)); err != nil {
|
|
||||||
return "", fmt.Errorf("%s %q: %w", s.ifname, cmd, err)
|
|
||||||
}
|
|
||||||
buf := make([]byte, 256)
|
|
||||||
n, err := unix.Read(fd, buf)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("%s %q: %w", s.ifname, cmd, err)
|
|
||||||
}
|
|
||||||
resp := strings.TrimSpace(string(buf[:n]))
|
|
||||||
if !strings.HasPrefix(resp, "ok") {
|
|
||||||
return "", fmt.Errorf("%s %q: %s", s.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
|
|
||||||
}
|
|
||||||
|
|
||||||
// A single bus hold; split write/read ops would let the driver's own SFP
|
|
||||||
// traffic consume the bridge's pending read.
|
|
||||||
func (s *sff) 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 := s.op(sb.String())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return parseHexBytes(resp, n)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *sff) eeprom(off byte, n int) ([]byte, error) {
|
|
||||||
return s.compound(0xA0, 0xA1, 500, n, []byte{off})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *sff) vendorPN() (string, error) {
|
|
||||||
pn, err := s.eeprom(40, 16)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(pn)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type mdioDev interface {
|
type mdioDev interface {
|
||||||
name() string
|
name() string
|
||||||
exec(func())
|
exec(func())
|
||||||
@@ -256,395 +95,6 @@ func devEEEAdvert(d mdioDev) (v uint16, err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
type bcm struct {
|
|
||||||
*sff
|
|
||||||
windowEnd time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func newBCM(t *sff) *bcm {
|
|
||||||
b := &bcm{sff: t}
|
|
||||||
t.exec(func() { t.admit = b.window })
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// The firmware's internal temp poll serves stale bridge reads for ~50 ms
|
|
||||||
// around it; work stays inside 3.4 s of an observed poll. A resident 0x0031 at
|
|
||||||
// expiry means the phase is unknown, so re-lock: arm, then take the true edge.
|
|
||||||
// Each taken edge immediately re-arms — the one CMD write per window lands at
|
|
||||||
// the start of the quiet period, maximally far from the next poll (writes near
|
|
||||||
// the poll are the µC-wedge risk), and every later expiry reads the phase
|
|
||||||
// without writing.
|
|
||||||
func (b *bcm) window() {
|
|
||||||
if time.Now().Add(bcmWindowFit).Before(b.windowEnd) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
armed := false
|
|
||||||
deadline := time.Now().Add(bcmFlipWait)
|
|
||||||
for {
|
|
||||||
v, err := b.mdioRead(bcmMMDVendor, bcmRegCmd)
|
|
||||||
if err != nil {
|
|
||||||
panic(fmt.Sprintf("%s: heartbeat poll: %v", b.ifname, err))
|
|
||||||
}
|
|
||||||
if v == bcmCmdResidentTemp {
|
|
||||||
if armed {
|
|
||||||
b.windowEnd = time.Now().Add(bcmWindow)
|
|
||||||
b.rearm()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
b.rearm()
|
|
||||||
armed = true
|
|
||||||
deadline = time.Now().Add(bcmFlipWait)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
armed = true
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
b.windowEnd = time.Now().Add(bcmWindow)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
time.Sleep(bcmFlipPoll)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *bcm) rearm() {
|
|
||||||
if _, err := b.waitStatus(func(st uint16) bool {
|
|
||||||
return st != bcmStInProgress && st != bcmStBusy
|
|
||||||
}); err != nil {
|
|
||||||
panic(fmt.Sprintf("%s: rearm: %v", b.ifname, err))
|
|
||||||
}
|
|
||||||
if err := b.mdioWrite(bcmMMDVendor, bcmRegCmd, bcmCmdGetPairSwap); err != nil {
|
|
||||||
panic(fmt.Sprintf("%s: rearm: %v", b.ifname, err))
|
|
||||||
}
|
|
||||||
if _, err := b.waitStatus(func(st uint16) bool {
|
|
||||||
return st == bcmStPass || st == bcmStError
|
|
||||||
}); err != nil {
|
|
||||||
panic(fmt.Sprintf("%s: rearm: %v", b.ifname, err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// The datasheet's completion handshake: poll STATUS on its 100 ms cadence
|
|
||||||
// until the wanted state, bounded by a deadline.
|
|
||||||
func (b *bcm) waitStatus(want func(uint16) bool) (uint16, error) {
|
|
||||||
deadline := time.Now().Add(bcmStatusTimeout)
|
|
||||||
for {
|
|
||||||
st, err := b.mdioRead(bcmMMDVendor, bcmRegStatus)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
if want(st) {
|
|
||||||
return st, nil
|
|
||||||
}
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
return 0, fmt.Errorf("%s: command handler stuck, status %#04x", b.ifname, st)
|
|
||||||
}
|
|
||||||
time.Sleep(bcmStatusPoll)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GETs must be invoked bare (pre-writing any DATA register leaves the handler
|
|
||||||
// executing as a no-op); SETs must pass their full parameter set (the handler
|
|
||||||
// executes stale DATA).
|
|
||||||
func (b *bcm) command(code uint16, params ...uint16) (data [5]uint16, err error) {
|
|
||||||
b.exec(func() {
|
|
||||||
if _, err = b.waitStatus(func(st uint16) bool {
|
|
||||||
return st != bcmStInProgress && st != bcmStBusy
|
|
||||||
}); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for i, p := range params {
|
|
||||||
if err = b.mdioWrite(bcmMMDVendor, bcmRegData1+uint16(i), p); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err = b.mdioWrite(bcmMMDVendor, bcmRegCmd, code); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var st uint16
|
|
||||||
if st, err = b.waitStatus(func(st uint16) bool {
|
|
||||||
return st == bcmStPass || st == bcmStError
|
|
||||||
}); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if st == bcmStError {
|
|
||||||
err = fmt.Errorf("%s: command %#04x returned ERROR", b.ifname, code)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(params) > 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for i := range data {
|
|
||||||
if data[i], err = b.mdioRead(bcmMMDVendor, bcmRegData1+uint16(i)); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *bcm) identify() (ident string, err error) {
|
|
||||||
b.exec(func() {
|
|
||||||
var hi, lo uint16
|
|
||||||
if hi, err = b.mdioRead(1, 2); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if lo, err = b.mdioRead(1, 3); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if hi != bcmPHYIDHi || lo != bcmPHYIDLo {
|
|
||||||
err = fmt.Errorf("%s: PHY ID %#04x:%#04x, want %#04x:%#04x",
|
|
||||||
b.ifname, hi, lo, bcmPHYIDHi, bcmPHYIDLo)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var sn []byte
|
|
||||||
if sn, err = b.eeprom(68, 16); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ident = "BCM84891L sn " + strings.TrimSpace(string(sn))
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *bcm) forceEEEOff() error {
|
|
||||||
_, err := b.command(bcmCmdSetEEEMode, 0x0000, 0x0000, 0x7A12, 0x0480, 0x0000)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *bcm) forceJumbo() error {
|
|
||||||
_, err := b.command(bcmCmdSetJumbo, 1, 0, 0, 0, 0)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Left to AN, master/slave is a per-training lottery and each training's DSP
|
|
||||||
// convergence moves per-pair SNR by up to ~3.6 dB; pinned roles at least keep
|
|
||||||
// every session measured under identical conditions.
|
|
||||||
func (b *bcm) forceRole(master bool) (err error) {
|
|
||||||
b.exec(func() {
|
|
||||||
var v uint16
|
|
||||||
if v, err = b.mdioRead(7, 32); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
v |= 0x8000
|
|
||||||
if master {
|
|
||||||
v |= 0x4000
|
|
||||||
} else {
|
|
||||||
v &^= 0x4000
|
|
||||||
}
|
|
||||||
err = b.mdioWrite(7, 32, v)
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *bcm) pairMap() (byte, error) {
|
|
||||||
d, err := b.command(bcmCmdGetPairSwap)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return byte(d[1]), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
rbI2CWrite = 0xA2
|
|
||||||
rbI2CRead = 0xA3
|
|
||||||
|
|
||||||
rbOffPassword byte = 0x7B
|
|
||||||
rbOffPage byte = 0x7F
|
|
||||||
rbOffCmd byte = 0x80
|
|
||||||
rbOffDevad byte = 0x81
|
|
||||||
rbOffValHi byte = 0x84
|
|
||||||
rbOffPartNum byte = 0xFA
|
|
||||||
|
|
||||||
rbPageMailbox byte = 3
|
|
||||||
|
|
||||||
rbCmdWrite byte = 0x01
|
|
||||||
rbCmdRead byte = 0x02
|
|
||||||
rbCmdDone byte = 0x04
|
|
||||||
|
|
||||||
rbReadDelayUs = 500
|
|
||||||
rbCmdPoll = 20 * time.Millisecond
|
|
||||||
// Matches the BCM allowance for a handler frozen by 10GBASE-T training.
|
|
||||||
rbCmdTimeout = 3 * time.Second
|
|
||||||
|
|
||||||
rbPHYIDHi uint16 = 0x002B
|
|
||||||
rbPHYIDLo uint16 = 0x0BF4
|
|
||||||
|
|
||||||
// IEEE margins land near 7-9 dB on a healthy short cable; far outside is
|
|
||||||
// another register's data.
|
|
||||||
rbGhostLow = -10.0
|
|
||||||
rbGhostHigh = 25.0
|
|
||||||
)
|
|
||||||
|
|
||||||
// Only the registers proven safe on this PHY (docs/modules/wiitek/): single
|
|
||||||
// reads in the vendor windows brick the µC permanently, so everything else
|
|
||||||
// refuses before touching hardware.
|
|
||||||
var rbReadSafe = map[uint16]map[uint16]bool{
|
|
||||||
1: {1: true, 2: true, 3: true, 133: true, 134: true, 135: true, 136: true, 147: true},
|
|
||||||
3: {32: true, 33: true},
|
|
||||||
7: {0: true, 33: true, 60: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
var rbWriteSafe = map[uint16]map[uint16]bool{
|
|
||||||
7: {0: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
type rollball struct {
|
|
||||||
*sff
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *rollball) i2cWrite(off byte, data ...byte) error {
|
|
||||||
var sb strings.Builder
|
|
||||||
fmt.Fprintf(&sb, "w %02x %02x", rbI2CWrite, off)
|
|
||||||
for _, v := range data {
|
|
||||||
fmt.Fprintf(&sb, " %02x", v)
|
|
||||||
}
|
|
||||||
_, err := r.op(sb.String())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *rollball) i2cRead(off byte, n int) ([]byte, error) {
|
|
||||||
return r.compound(rbI2CWrite, rbI2CRead, rbReadDelayUs, n, []byte{off})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *rollball) unlock() error {
|
|
||||||
if err := r.i2cWrite(rbOffPage, rbPageMailbox); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return r.i2cWrite(rbOffPassword, 0xFF, 0xFF, 0xFF, 0xFF)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The µC can be mid-service of an earlier session's command when this one is
|
|
||||||
// issued: it completes the old one, leaving DONE and a stale value in the
|
|
||||||
// block, and a status sample taken before the new command commits reads them
|
|
||||||
// as this command's (seen live: PHY ID high word answered by an orphaned 7.60
|
|
||||||
// read). So status is never sampled before a full poll gap, the value rides
|
|
||||||
// in the same block read as the status, and a completion only counts when the
|
|
||||||
// block echoes this command's devad/reg.
|
|
||||||
func (r *rollball) mbox(cmd byte, devad, reg, val uint16) (out [2]byte, err error) {
|
|
||||||
if err = r.unlock(); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err = r.i2cWrite(rbOffDevad, byte(devad), byte(reg>>8), byte(reg)); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if cmd == rbCmdWrite {
|
|
||||||
if err = r.i2cWrite(rbOffValHi, byte(val>>8), byte(val)); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err = r.i2cWrite(rbOffCmd, cmd); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
deadline := time.Now().Add(rbCmdTimeout)
|
|
||||||
for {
|
|
||||||
time.Sleep(rbCmdPoll)
|
|
||||||
var d []byte
|
|
||||||
if d, err = r.i2cRead(rbOffCmd, 6); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if d[0] == rbCmdDone && d[1] == byte(devad) &&
|
|
||||||
d[2] == byte(reg>>8) && d[3] == byte(reg) {
|
|
||||||
out[0], out[1] = d[4], d[5]
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
err = fmt.Errorf("%s: mailbox %d.%#04x stuck at %#02x", r.ifname, devad, reg, d[0])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func rbGuard(safe map[uint16]map[uint16]bool, ifname, what string, devad, reg uint16) {
|
|
||||||
if !safe[devad][reg] {
|
|
||||||
panic(fmt.Sprintf("%s: refusing MDIO %s %d.%#04x: outside the proven-safe set",
|
|
||||||
ifname, what, devad, reg))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *rollball) mdioRead(devad, reg uint16) (uint16, error) {
|
|
||||||
rbGuard(rbReadSafe, r.ifname, "read", devad, reg)
|
|
||||||
d, err := r.mbox(rbCmdRead, devad, reg, 0)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return uint16(d[0])<<8 | uint16(d[1]), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *rollball) mdioWrite(devad, reg, val uint16) error {
|
|
||||||
rbGuard(rbWriteSafe, r.ifname, "write", devad, reg)
|
|
||||||
_, err := r.mbox(rbCmdWrite, devad, reg, val)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *rollball) identify() (ident string, err error) {
|
|
||||||
r.exec(func() {
|
|
||||||
var hi, lo uint16
|
|
||||||
if hi, err = r.mdioRead(1, 2); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if lo, err = r.mdioRead(1, 3); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if hi != rbPHYIDHi || lo != rbPHYIDLo {
|
|
||||||
err = fmt.Errorf("%s: PHY ID %#04x:%#04x, want %#04x:%#04x",
|
|
||||||
r.ifname, hi, lo, rbPHYIDHi, rbPHYIDLo)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err = r.unlock(); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var part []byte
|
|
||||||
if part, err = r.i2cRead(rbOffPartNum, 1); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var sn []byte
|
|
||||||
if sn, err = r.eeprom(68, 16); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ident = fmt.Sprintf("CUX3610 sn %s (A2.250=%d)", strings.TrimSpace(string(sn)), part[0])
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *rollball) snrMargins() (out [4]float64, err error) {
|
|
||||||
r.exec(func() {
|
|
||||||
for i := range out {
|
|
||||||
var v uint16
|
|
||||||
if v, err = r.mdioRead(1, uint16(133+i)); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
m := (float64(v) - 0x8000) / 10
|
|
||||||
if m < rbGhostLow || m > rbGhostHigh {
|
|
||||||
panic(fmt.Sprintf("%s: ghost SNR margin %.1f dB (1.%d=%#04x)", r.ifname, m, 133+i, v))
|
|
||||||
}
|
|
||||||
out[i] = m
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
phyInterval = time.Second
|
phyInterval = time.Second
|
||||||
phyStale = 5 * time.Second
|
phyStale = 5 * time.Second
|
||||||
@@ -811,7 +261,6 @@ type cableInfo struct {
|
|||||||
ecd ecdResult
|
ecd ecdResult
|
||||||
maps [2]byte
|
maps [2]byte
|
||||||
haveMaps [2]bool
|
haveMaps [2]bool
|
||||||
failed bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c cableInfo) metresString() string {
|
func (c cableInfo) metresString() string {
|
||||||
@@ -867,15 +316,11 @@ func pairSwapped(i int, c cableInfo) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The whole cable verdict in one cell: the length when healthy, the first
|
// The whole cable verdict in one cell: the length when healthy, the first
|
||||||
// faulted pair's verdict when not, "xover" for a pair swap, "fail" for a diag
|
// faulted pair's verdict when not, "xover" for a pair swap.
|
||||||
// that never delivered.
|
|
||||||
func cableSummary(cable cableInfo, measuring bool) (string, int) {
|
func cableSummary(cable cableInfo, measuring bool) (string, int) {
|
||||||
if measuring {
|
if measuring {
|
||||||
return "-", clsNone
|
return "-", clsNone
|
||||||
}
|
}
|
||||||
if cable.failed {
|
|
||||||
return "fail", clsBad
|
|
||||||
}
|
|
||||||
anyData, anySwap := false, false
|
anyData, anySwap := false, false
|
||||||
for i, v := range cable.ecd.verdicts {
|
for i, v := range cable.ecd.verdicts {
|
||||||
if v != 0 {
|
if v != 0 {
|
||||||
@@ -928,49 +373,6 @@ type ecdResult struct {
|
|||||||
metres [4]int
|
metres [4]int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bcm) cableDiag() (res ecdResult, err error) {
|
|
||||||
b.exec(func() {
|
|
||||||
var ctrl uint16
|
|
||||||
if ctrl, err = b.mdioRead(bcmMMDVendor, bcmRegECDCtrl); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err = b.mdioWrite(bcmMMDVendor, bcmRegECDCtrl, ctrl&^0xF400|0x8400); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
deadline := time.Now().Add(ecdDeadline)
|
|
||||||
for {
|
|
||||||
if ctrl, err = b.mdioRead(bcmMMDVendor, bcmRegECDCtrl); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if ctrl&0x0800 == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
err = fmt.Errorf("%s: cable diag still busy after %s", b.ifname, ecdDeadline)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
time.Sleep(ecdPoll)
|
|
||||||
}
|
|
||||||
b.window()
|
|
||||||
var v uint16
|
|
||||||
if v, err = b.mdioRead(1, bcmRegECDResult); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for i := range res.verdicts {
|
|
||||||
res.verdicts[i] = int(v>>(4*i)) & 0xF
|
|
||||||
if res.verdicts[i] > pairXtalk {
|
|
||||||
panic(fmt.Sprintf("%s: ghost ECD verdict %#04x", b.ifname, v))
|
|
||||||
}
|
|
||||||
var m uint16
|
|
||||||
if m, err = b.mdioRead(1, bcmRegECDLen+uint16(i)); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
res.metres[i] = int(m)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func bcmEnd(mods []*phyModule) *bcm {
|
func bcmEnd(mods []*phyModule) *bcm {
|
||||||
for _, m := range mods {
|
for _, m := range mods {
|
||||||
if b, ok := m.dev.(*bcm); ok {
|
if b, ok := m.dev.(*bcm); ok {
|
||||||
@@ -982,7 +384,7 @@ func bcmEnd(mods []*phyModule) *bcm {
|
|||||||
|
|
||||||
// The pollers are held silent throughout; pair maps are read after the
|
// The pollers are held silent throughout; pair maps are read after the
|
||||||
// relink, so the MDI resolution is the fresh one.
|
// relink, so the MDI resolution is the fresh one.
|
||||||
func measureCable(mods []*phyModule, waitRelink bool, done *atomic.Bool) (cableInfo, bool, error) {
|
func measureCable(mods []*phyModule, done *atomic.Bool) (cableInfo, error) {
|
||||||
for _, m := range mods {
|
for _, m := range mods {
|
||||||
m.busy.Store(true)
|
m.busy.Store(true)
|
||||||
}
|
}
|
||||||
@@ -998,27 +400,23 @@ func measureCable(mods []*phyModule, waitRelink bool, done *atomic.Bool) (cableI
|
|||||||
end := bcmEnd(mods)
|
end := bcmEnd(mods)
|
||||||
c.ecd, err = end.cableDiag()
|
c.ecd, err = end.cableDiag()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c, false, err
|
return c, err
|
||||||
}
|
}
|
||||||
if err = devRestartAN(end); err != nil {
|
if err = devRestartAN(end); err != nil {
|
||||||
return c, false, err
|
return c, err
|
||||||
}
|
|
||||||
relinked := false
|
|
||||||
if waitRelink {
|
|
||||||
names := [2]string{mods[0].dev.name(), mods[1].dev.name()}
|
|
||||||
_, relinked = waitCarrier(names, done)
|
|
||||||
}
|
}
|
||||||
|
waitCarrier([2]string{mods[0].dev.name(), mods[1].dev.name()}, done)
|
||||||
for i, m := range mods {
|
for i, m := range mods {
|
||||||
b, ok := m.dev.(*bcm)
|
b, ok := m.dev.(*bcm)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if c.maps[i], err = b.pairMap(); err != nil {
|
if c.maps[i], err = b.pairMap(); err != nil {
|
||||||
return c, false, err
|
return c, err
|
||||||
}
|
}
|
||||||
c.haveMaps[i] = true
|
c.haveMaps[i] = true
|
||||||
}
|
}
|
||||||
return c, relinked, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type cableDiag struct {
|
type cableDiag struct {
|
||||||
@@ -1043,15 +441,18 @@ func (c *cableDiag) snapshot() (cableInfo, bool) {
|
|||||||
return c.info, c.measuring.Load()
|
return c.info, c.measuring.Load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A measure that errors means the diag transport or the µC is broken, and a
|
||||||
|
// tester that cannot run its diagnostics must not keep testing: the failure is
|
||||||
|
// fatal, and the reboot's driver reload is also the wedged-µC recovery.
|
||||||
func (c *cableDiag) kick(done *atomic.Bool) bool {
|
func (c *cableDiag) kick(done *atomic.Bool) bool {
|
||||||
if !c.measuring.CompareAndSwap(false, true) {
|
if !c.measuring.CompareAndSwap(false, true) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
defer holdPanic()
|
defer holdPanic()
|
||||||
info, _, err := measureCable(c.mods, true, done)
|
info, err := measureCable(c.mods, done)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
info = cableInfo{failed: true}
|
panic(fmt.Sprintf("cable measure: %v", err))
|
||||||
}
|
}
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.info = info
|
c.info = info
|
||||||
@@ -1095,15 +496,11 @@ func openModules(names [2]string) ([]*phyModule, [2]string, error) {
|
|||||||
return mods, idents, nil
|
return mods, idents, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitCarrier(names [2]string, done *atomic.Bool) (time.Duration, bool) {
|
func waitCarrier(names [2]string, done *atomic.Bool) {
|
||||||
start := time.Now()
|
deadline := time.Now().Add(linkWaitSpan)
|
||||||
deadline := start.Add(linkWaitSpan)
|
for !(carrierUp(names[0]) && carrierUp(names[1])) {
|
||||||
for {
|
if time.Now().After(deadline) || done.Load() {
|
||||||
if carrierUp(names[0]) && carrierUp(names[1]) {
|
return
|
||||||
return time.Since(start), true
|
|
||||||
}
|
|
||||||
if time.Now().After(deadline) || (done != nil && done.Load()) {
|
|
||||||
return time.Since(start), false
|
|
||||||
}
|
}
|
||||||
time.Sleep(linkWaitPoll)
|
time.Sleep(linkWaitPoll)
|
||||||
}
|
}
|
||||||
@@ -1191,4 +588,3 @@ func moduleChecks(mods []*phyModule, names [2]string) []checkResult {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ func TestCableSummary(t *testing.T) {
|
|||||||
{"one-end map only", cableInfo{ecd: healthy, maps: [2]byte{pairIdentityMap, 0}, haveMaps: [2]bool{true, false}}, false, "49", clsGood},
|
{"one-end map only", cableInfo{ecd: healthy, maps: [2]byte{pairIdentityMap, 0}, haveMaps: [2]bool{true, false}}, false, "49", clsGood},
|
||||||
{"fault", cableInfo{ecd: ecdResult{verdicts: [4]int{pairOK, pairShort, pairXtalk, pairOK}, metres: [4]int{50, 12, 30, 51}}}, false, "SHORT", clsBad},
|
{"fault", cableInfo{ecd: ecdResult{verdicts: [4]int{pairOK, pairShort, pairXtalk, pairOK}, metres: [4]int{50, 12, 30, 51}}}, false, "SHORT", clsBad},
|
||||||
{"fault beats swap", cableInfo{ecd: ecdResult{verdicts: [4]int{pairOpen, pairOK, pairOK, pairOK}}, maps: [2]byte{pairIdentityMap, 0xE1}, haveMaps: [2]bool{true, true}}, false, "OPEN", clsBad},
|
{"fault beats swap", cableInfo{ecd: ecdResult{verdicts: [4]int{pairOpen, pairOK, pairOK, pairOK}}, maps: [2]byte{pairIdentityMap, 0xE1}, haveMaps: [2]bool{true, true}}, false, "OPEN", clsBad},
|
||||||
{"diag failed", cableInfo{failed: true}, false, "fail", clsBad},
|
|
||||||
{"no diag yet", cableInfo{}, false, "-", clsNone},
|
{"no diag yet", cableInfo{}, false, "-", clsNone},
|
||||||
{"measuring", cableInfo{ecd: healthy, maps: [2]byte{pairIdentityMap, pairIdentityMap}, haveMaps: [2]bool{true, true}}, true, "-", clsNone},
|
{"measuring", cableInfo{ecd: healthy, maps: [2]byte{pairIdentityMap, pairIdentityMap}, haveMaps: [2]bool{true, true}}, true, "-", clsNone},
|
||||||
} {
|
} {
|
||||||
@@ -106,4 +105,3 @@ func TestSNRClass(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -185,6 +185,9 @@ func (t *streamTable) rule(label string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *streamTable) emit(cells []string) []string {
|
func (t *streamTable) emit(cells []string) []string {
|
||||||
|
if len(cells) != len(t.cols) {
|
||||||
|
panic(fmt.Sprintf("row has %d cells for %d columns", len(cells), len(t.cols)))
|
||||||
|
}
|
||||||
var out []string
|
var out []string
|
||||||
if t.sinceHeader == 0 || (t.headerEvery > 0 && t.sinceHeader >= t.headerEvery) {
|
if t.sinceHeader == 0 || (t.headerEvery > 0 && t.sinceHeader >= t.headerEvery) {
|
||||||
out = append(out, t.headerLines()...)
|
out = append(out, t.headerLines()...)
|
||||||
@@ -192,11 +195,7 @@ func (t *streamTable) emit(cells []string) []string {
|
|||||||
}
|
}
|
||||||
var padded []string
|
var padded []string
|
||||||
for i, c := range t.cols {
|
for i, c := range t.cols {
|
||||||
v := ""
|
padded = append(padded, pad(cells[i], c.width, c.right))
|
||||||
if i < len(cells) {
|
|
||||||
v = cells[i]
|
|
||||||
}
|
|
||||||
padded = append(padded, pad(v, c.width, c.right))
|
|
||||||
}
|
}
|
||||||
t.sinceHeader++
|
t.sinceHeader++
|
||||||
return append(out, t.join(padded, paint(" │ ", cGrey)))
|
return append(out, t.join(padded, paint(" │ ", cGrey)))
|
||||||
|
|||||||
+186
@@ -0,0 +1,186 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
rbI2CWrite = 0xA2
|
||||||
|
rbI2CRead = 0xA3
|
||||||
|
|
||||||
|
rbOffPassword byte = 0x7B
|
||||||
|
rbOffPage byte = 0x7F
|
||||||
|
rbOffCmd byte = 0x80
|
||||||
|
rbOffDevad byte = 0x81
|
||||||
|
rbOffValHi byte = 0x84
|
||||||
|
rbOffPartNum byte = 0xFA
|
||||||
|
|
||||||
|
rbPageMailbox byte = 3
|
||||||
|
|
||||||
|
rbCmdWrite byte = 0x01
|
||||||
|
rbCmdRead byte = 0x02
|
||||||
|
rbCmdDone byte = 0x04
|
||||||
|
|
||||||
|
rbReadDelayUs = 500
|
||||||
|
rbCmdPoll = 20 * time.Millisecond
|
||||||
|
// Matches the BCM allowance for a handler frozen by 10GBASE-T training.
|
||||||
|
rbCmdTimeout = 3 * time.Second
|
||||||
|
|
||||||
|
rbPHYIDHi uint16 = 0x002B
|
||||||
|
rbPHYIDLo uint16 = 0x0BF4
|
||||||
|
|
||||||
|
// IEEE margins land near 7-9 dB on a healthy short cable; far outside is
|
||||||
|
// another register's data.
|
||||||
|
rbGhostLow = -10.0
|
||||||
|
rbGhostHigh = 25.0
|
||||||
|
)
|
||||||
|
|
||||||
|
// Only the registers proven safe on this PHY (docs/modules/wiitek/): single
|
||||||
|
// reads in the vendor windows brick the µC permanently, so everything else
|
||||||
|
// refuses before touching hardware.
|
||||||
|
var rbReadSafe = map[uint16]map[uint16]bool{
|
||||||
|
1: {1: true, 2: true, 3: true, 133: true, 134: true, 135: true, 136: true, 147: true},
|
||||||
|
3: {32: true, 33: true},
|
||||||
|
7: {0: true, 33: true, 60: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
var rbWriteSafe = map[uint16]map[uint16]bool{
|
||||||
|
7: {0: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
type rollball struct {
|
||||||
|
*sff
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rollball) i2cWrite(off byte, data ...byte) error {
|
||||||
|
var sb strings.Builder
|
||||||
|
fmt.Fprintf(&sb, "w %02x %02x", rbI2CWrite, off)
|
||||||
|
for _, v := range data {
|
||||||
|
fmt.Fprintf(&sb, " %02x", v)
|
||||||
|
}
|
||||||
|
_, err := r.op(sb.String())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rollball) i2cRead(off byte, n int) ([]byte, error) {
|
||||||
|
return r.compound(rbI2CWrite, rbI2CRead, rbReadDelayUs, n, []byte{off})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rollball) unlock() error {
|
||||||
|
if err := r.i2cWrite(rbOffPage, rbPageMailbox); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return r.i2cWrite(rbOffPassword, 0xFF, 0xFF, 0xFF, 0xFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The µC can be mid-service of an earlier session's command when this one is
|
||||||
|
// issued: it completes the old one, leaving DONE and a stale value in the
|
||||||
|
// block, and a status sample taken before the new command commits reads them
|
||||||
|
// as this command's (seen live: PHY ID high word answered by an orphaned 7.60
|
||||||
|
// read). So status is never sampled before a full poll gap, the value rides
|
||||||
|
// in the same block read as the status, and a completion only counts when the
|
||||||
|
// block echoes this command's devad/reg.
|
||||||
|
func (r *rollball) mbox(cmd byte, devad, reg, val uint16) (out [2]byte, err error) {
|
||||||
|
if err = r.unlock(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = r.i2cWrite(rbOffDevad, byte(devad), byte(reg>>8), byte(reg)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cmd == rbCmdWrite {
|
||||||
|
if err = r.i2cWrite(rbOffValHi, byte(val>>8), byte(val)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = r.i2cWrite(rbOffCmd, cmd); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(rbCmdTimeout)
|
||||||
|
for {
|
||||||
|
time.Sleep(rbCmdPoll)
|
||||||
|
var d []byte
|
||||||
|
if d, err = r.i2cRead(rbOffCmd, 6); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if d[0] == rbCmdDone && d[1] == byte(devad) &&
|
||||||
|
d[2] == byte(reg>>8) && d[3] == byte(reg) {
|
||||||
|
out[0], out[1] = d[4], d[5]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
err = fmt.Errorf("%s: mailbox %d.%#04x stuck at %#02x", r.ifname, devad, reg, d[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rbGuard(safe map[uint16]map[uint16]bool, ifname, what string, devad, reg uint16) {
|
||||||
|
if !safe[devad][reg] {
|
||||||
|
panic(fmt.Sprintf("%s: refusing MDIO %s %d.%#04x: outside the proven-safe set",
|
||||||
|
ifname, what, devad, reg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rollball) mdioRead(devad, reg uint16) (uint16, error) {
|
||||||
|
rbGuard(rbReadSafe, r.ifname, "read", devad, reg)
|
||||||
|
d, err := r.mbox(rbCmdRead, devad, reg, 0)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return uint16(d[0])<<8 | uint16(d[1]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rollball) mdioWrite(devad, reg, val uint16) error {
|
||||||
|
rbGuard(rbWriteSafe, r.ifname, "write", devad, reg)
|
||||||
|
_, err := r.mbox(rbCmdWrite, devad, reg, val)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rollball) identify() (ident string, err error) {
|
||||||
|
r.exec(func() {
|
||||||
|
var hi, lo uint16
|
||||||
|
if hi, err = r.mdioRead(1, 2); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if lo, err = r.mdioRead(1, 3); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hi != rbPHYIDHi || lo != rbPHYIDLo {
|
||||||
|
err = fmt.Errorf("%s: PHY ID %#04x:%#04x, want %#04x:%#04x",
|
||||||
|
r.ifname, hi, lo, rbPHYIDHi, rbPHYIDLo)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = r.unlock(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var part []byte
|
||||||
|
if part, err = r.i2cRead(rbOffPartNum, 1); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var sn []byte
|
||||||
|
if sn, err = r.eeprom(68, 16); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ident = fmt.Sprintf("CUX3610 sn %s (A2.250=%d)", strings.TrimSpace(string(sn)), part[0])
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rollball) snrMargins() (out [4]float64, err error) {
|
||||||
|
r.exec(func() {
|
||||||
|
for i := range out {
|
||||||
|
var v uint16
|
||||||
|
if v, err = r.mdioRead(1, uint16(133+i)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m := (float64(v) - 0x8000) / 10
|
||||||
|
if m < rbGhostLow || m > rbGhostHigh {
|
||||||
|
panic(fmt.Sprintf("%s: ghost SNR margin %.1f dB (1.%d=%#04x)", r.ifname, m, 133+i, v))
|
||||||
|
}
|
||||||
|
out[i] = m
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sff struct {
|
||||||
|
ifname string
|
||||||
|
path string
|
||||||
|
|
||||||
|
// Every transport touch happens on the loop goroutine: requests execute
|
||||||
|
// one at a time, each admitted by the module type's own gate first.
|
||||||
|
reqs chan func()
|
||||||
|
admit func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func openSFF(ifname string) (*sff, 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)
|
||||||
|
}
|
||||||
|
s := &sff{
|
||||||
|
ifname: ifname,
|
||||||
|
path: "/sys/kernel/debug/ixgbe/" + filepath.Base(devLink) + "/sff_i2c",
|
||||||
|
reqs: make(chan func()),
|
||||||
|
admit: func() {},
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(s.path); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w (patched ixgbe?)", ifname, err)
|
||||||
|
}
|
||||||
|
go s.loop()
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sff) loop() {
|
||||||
|
defer holdPanic()
|
||||||
|
for fn := range s.reqs {
|
||||||
|
s.admit()
|
||||||
|
fn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sff) exec(fn func()) {
|
||||||
|
done := make(chan struct{})
|
||||||
|
s.reqs <- func() { fn(); close(done) }
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sff) name() string { return s.ifname }
|
||||||
|
|
||||||
|
func (s *sff) op(cmd string) (string, error) {
|
||||||
|
fd, err := unix.Open(s.path, unix.O_RDWR, 0)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%s: %w", s.path, err)
|
||||||
|
}
|
||||||
|
defer unix.Close(fd)
|
||||||
|
if _, err := unix.Write(fd, []byte(cmd)); err != nil {
|
||||||
|
return "", fmt.Errorf("%s %q: %w", s.ifname, cmd, err)
|
||||||
|
}
|
||||||
|
buf := make([]byte, 256)
|
||||||
|
n, err := unix.Read(fd, buf)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%s %q: %w", s.ifname, cmd, err)
|
||||||
|
}
|
||||||
|
resp := strings.TrimSpace(string(buf[:n]))
|
||||||
|
if !strings.HasPrefix(resp, "ok") {
|
||||||
|
return "", fmt.Errorf("%s %q: %s", s.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
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single bus hold; split write/read ops would let the driver's own SFP
|
||||||
|
// traffic consume the bridge's pending read.
|
||||||
|
func (s *sff) 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 := s.op(sb.String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parseHexBytes(resp, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sff) eeprom(off byte, n int) ([]byte, error) {
|
||||||
|
return s.compound(0xA0, 0xA1, 500, n, []byte{off})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sff) vendorPN() (string, error) {
|
||||||
|
pn, err := s.eeprom(40, 16)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(pn)), nil
|
||||||
|
}
|
||||||
@@ -492,7 +492,16 @@ func (d *display) drawRows(v view, phy phyDisplay, measuring bool, nv noiseView,
|
|||||||
d.colMark(resetR, y, markCheck, uiGood)
|
d.colMark(resetR, y, markCheck, uiGood)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
f := i - 1
|
f := -1
|
||||||
|
for j, c := range faultClasses {
|
||||||
|
if c.label == label {
|
||||||
|
f = j
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f < 0 {
|
||||||
|
panic("panel row " + label + " has no fault class")
|
||||||
|
}
|
||||||
if n := faultClasses[f].get(v.window); n > 0 {
|
if n := faultClasses[f].get(v.window); n > 0 {
|
||||||
d.colText(d.textB, nowR, y, scaleCount(n), uiCrit)
|
d.colText(d.textB, nowR, y, scaleCount(n), uiCrit)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user