diff --git a/harness/main.go b/harness/main.go index a9871a6..e9d2afe 100644 --- a/harness/main.go +++ b/harness/main.go @@ -20,6 +20,8 @@ import ( "strings" "syscall" "time" + + "golang.org/x/sys/unix" ) var errInterrupted = errors.New("interrupted") @@ -43,18 +45,40 @@ func main() { os.Exit(1) } + restoreTerm := rawKeys() + var err error if *fuzzN > 0 { err = fuzz(*fuzzN, *runFor, flag.Args()) } else { err = run(*runFor, *at, os.Stdout, flag.Args()) } + restoreTerm() if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } +// Console keys reach cabletest as they are pressed, the same as at the +// appliance; isig stays on so ^C still stops the run through the relay. +func rawKeys() func() { + fd := int(os.Stdin.Fd()) + orig, err := unix.IoctlGetTermios(fd, unix.TCGETS) + if err != nil { + return func() {} + } + saved := *orig + raw := *orig + raw.Lflag &^= unix.ICANON | unix.ECHO + raw.Cc[unix.VMIN] = 1 + raw.Cc[unix.VTIME] = 0 + if unix.IoctlSetTermios(fd, unix.TCSETS, &raw) != nil { + return func() {} + } + return func() { unix.IoctlSetTermios(fd, unix.TCSETS, &saved) } +} + func fuzz(n int, runFor time.Duration, args []string) error { failed := 0 for i := 1; i <= n; i++ { @@ -133,6 +157,13 @@ func run(runFor time.Duration, at string, out io.Writer, args []string) error { // killed when go run dies, and an orphan holding the wire poisons every // measurement after it. cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + // The child's own process group makes the tty off limits to it (reading + // from the background raises SIGTTIN), so the harness reads and forwards. + stdin, err := cmd.StdinPipe() + if err != nil { + return err + } + go io.Copy(stdin, os.Stdin) if err := cmd.Start(); err != nil { return err } diff --git a/input.go b/input.go index 369c9d1..78c3bc1 100644 --- a/input.go +++ b/input.go @@ -160,11 +160,11 @@ func watchTouch(w, h int) (*touchState, error) { return state, nil } -// Delivers a value every time space is pressed. Puts the terminal in -// non-canonical mode so the keypress arrives without waiting for a newline, -// and returns a function that restores the original settings. -func watchSpace() (<-chan struct{}, func()) { - ch := make(chan struct{}, 1) +// Delivers each key as it is pressed. Puts the terminal in non-canonical mode +// so keypresses arrive without waiting for a newline, and returns a function +// that restores the original settings. +func watchKeys() (<-chan byte, func()) { + ch := make(chan byte, 8) restore := func() {} fd := int(os.Stdin.Fd()) @@ -187,11 +187,8 @@ func watchSpace() (<-chan struct{}, func()) { return } for _, b := range buf[:n] { - if b != ' ' { - continue - } select { - case ch <- struct{}{}: + case ch <- b: default: } } diff --git a/main.go b/main.go index ef2df76..c0bf168 100644 --- a/main.go +++ b/main.go @@ -56,8 +56,8 @@ func resetAll(dirs []*direction, mods []*phyModule) time.Time { // 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 + info, rng, measuring := diag.snapshot() + return phyDisplayFrom(info, rng, measuring, modules[0].view(), modules[1].view()), measuring } var intervalCols = []colSpec{ @@ -141,7 +141,8 @@ func main() { } const ( - reportInterval = time.Second + reportInterval = time.Second + autoMeasurePeriod = 60 * time.Second // Deliberately not tied to the refresh: letting a slow or blocked draw set // the sampling clock would stretch the window it reports. sampleInterval = 16 * time.Millisecond @@ -320,7 +321,7 @@ func run(aName, bName string) (err error) { sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) - space, restoreTerm := watchSpace() + keys, restoreTerm := watchKeys() defer restoreTerm() disp, err := newDisplay() @@ -345,6 +346,7 @@ func run(aName, bName string) (err error) { // hide or revert afterwards. kickMeasure := func() { if diag.kick(&done) { + diag.resetRange() start = resetAll(dirs, modules) hist.reset() } @@ -359,6 +361,8 @@ func run(aName, bName string) (err error) { views := make([]view, len(dirs)) rows := make([]view, len(dirs)) stats := &streamTable{cols: intervalCols, headerEvery: 20} + var autoOn bool + var autoAnchor time.Time for { select { case p := <-fatalCh: @@ -366,8 +370,13 @@ func run(aName, bName string) (err error) { case <-sig: return nil // The verdict lives in the cable cell, not its own line. - case <-space: - kickMeasure() + case b := <-keys: + switch b { + case ' ', 'r': + kickMeasure() + case 'a': + autoOn, autoAnchor = !autoOn, time.Now() + } case <-disp.fb.flips: now := time.Now() px, py, down := touch.get() @@ -375,6 +384,17 @@ func run(aName, bName string) (err error) { if disp.holdReset(x, y, down, now) { kickMeasure() } + if disp.tapAuto(x, y, down) { + autoOn, autoAnchor = !autoOn, now + } + autoFrac := 0.0 + if autoOn { + autoFrac = float64(now.Sub(autoAnchor)) / float64(autoMeasurePeriod) + if autoFrac >= 1 { + diag.kick(&done) + autoAnchor, autoFrac = now, 0 + } + } disp.showVersion = down && disp.versionSpot.contains(x, y) for i, d := range dirs { views[i] = d.displayView() @@ -383,7 +403,7 @@ func run(aName, bName string) (err error) { phy, measuring := phyView(diag, modules) hist.sample(now, v, phy, measuring, target) if err := disp.render(v, now.Sub(start), phy, measuring, - hist.view(now), target); err != nil { + hist.view(now), target, autoOn, autoFrac); err != nil { return err } case now := <-tick.C: diff --git a/phy.go b/phy.go index 76f5eef..095f1a1 100644 --- a/phy.go +++ b/phy.go @@ -267,7 +267,7 @@ type cableInfo struct { haveMaps [2]bool } -func (c cableInfo) metresString() string { +func (c cableInfo) metresValue() (int, bool) { sum, n := 0, 0 for i, v := range c.ecd.verdicts { if v == pairOK { @@ -276,9 +276,39 @@ func (c cableInfo) metresString() string { } } if n == 0 { + return 0, false + } + return (sum + n/2) / n, true +} + +func (c cableInfo) metresString() string { + m, ok := c.metresValue() + if !ok { return "-" } - return fmt.Sprintf("%d", (sum+n/2)/n) + return fmt.Sprintf("%d", m) +} + +type cableRange struct { + have bool + min, max int +} + +func (r *cableRange) add(m int) { + if !r.have || m < r.min { + r.min = m + } + if !r.have || m > r.max { + r.max = m + } + r.have = true +} + +func (r cableRange) String() string { + if r.min == r.max { + return fmt.Sprintf("%d", r.min) + } + return fmt.Sprintf("%d-%d", r.min, r.max) } const ( @@ -348,7 +378,7 @@ func cableSummary(cable cableInfo, measuring bool) (string, int) { // The margin is the worst pair across the ends that measure SNR (the Wiitek's // IEEE 1.133–136), gated on the whole pair being fresh and linked. -func phyDisplayFrom(cable cableInfo, measuring bool, a, b phyModView) phyDisplay { +func phyDisplayFrom(cable cableInfo, rng cableRange, measuring bool, a, b phyModView) phyDisplay { d := phyDisplay{ haveSNR: a.fresh && b.fresh && a.link && b.link && (a.haveSNR || b.haveSNR), corrected: a.blocks + a.ber + a.retrain + b.blocks + b.ber + b.retrain, @@ -369,6 +399,9 @@ func phyDisplayFrom(cable cableInfo, measuring bool, a, b phyModView) phyDisplay } } d.metres, d.metresClass = cableSummary(cable, measuring) + if d.metresClass == clsGood && rng.have { + d.metres = rng.String() + } return d } @@ -444,16 +477,23 @@ type cableDiag struct { mu sync.Mutex info cableInfo + rng cableRange } func newCableDiag(mods []*phyModule, info cableInfo) *cableDiag { return &cableDiag{mods: mods, info: info} } -func (c *cableDiag) snapshot() (cableInfo, bool) { +func (c *cableDiag) snapshot() (cableInfo, cableRange, bool) { c.mu.Lock() defer c.mu.Unlock() - return c.info, c.measuring.Load() + return c.info, c.rng, c.measuring.Load() +} + +func (c *cableDiag) resetRange() { + c.mu.Lock() + c.rng = cableRange{} + c.mu.Unlock() } // A measure that errors means the diag transport or the µC is broken, and a @@ -471,6 +511,11 @@ func (c *cableDiag) kick(done *atomic.Bool) bool { } c.mu.Lock() c.info = info + if _, cls := cableSummary(info, false); cls == clsGood { + if m, ok := info.metresValue(); ok { + c.rng.add(m) + } + } c.mu.Unlock() c.measuring.Store(false) }() diff --git a/phy_test.go b/phy_test.go index e029fc2..6e4f1cd 100644 --- a/phy_test.go +++ b/phy_test.go @@ -14,7 +14,7 @@ func TestPhyDisplayWorstMarginAndFault(t *testing.T) { } a := freshMod([4]float64{5, 5, 2, 0.5}) b := freshMod([4]float64{4, 5, 5, 5}) - d := phyDisplayFrom(cable, false, a, b) + d := phyDisplayFrom(cable, cableRange{}, false, a, b) if !d.haveSNR || d.worstMargin != 0.5 { t.Errorf("worstMargin = %v (have %v), want 0.5", d.worstMargin, d.haveSNR) @@ -27,15 +27,15 @@ func TestPhyDisplayWorstMarginAndFault(t *testing.T) { func TestPhyDisplaySNREndOwnsMargin(t *testing.T) { wiitek := freshMod([4]float64{9.2, 7.2, 7.0, 8.7}) fs := phyModView{fresh: true, link: true} - d := phyDisplayFrom(cableInfo{}, false, wiitek, fs) + d := phyDisplayFrom(cableInfo{}, cableRange{}, false, wiitek, fs) if !d.haveSNR || d.worstMargin != 7.0 { t.Errorf("worstMargin = %v (have %v), want the SNR end's 7.0", d.worstMargin, d.haveSNR) } - d = phyDisplayFrom(cableInfo{}, false, fs, wiitek) + d = phyDisplayFrom(cableInfo{}, cableRange{}, false, fs, wiitek) if !d.haveSNR || d.worstMargin != 7.0 { t.Errorf("swapped ends: worstMargin = %v, want 7.0", d.worstMargin) } - d = phyDisplayFrom(cableInfo{}, false, fs, fs) + d = phyDisplayFrom(cableInfo{}, cableRange{}, false, fs, fs) if d.haveSNR { t.Error("a pair with no SNR end should withhold snr") } @@ -71,7 +71,7 @@ func TestPhyDisplayStaleGoesDim(t *testing.T) { maps: [2]byte{pairIdentityMap, pairIdentityMap}, haveMaps: [2]bool{true, true}, } - d := phyDisplayFrom(cable, false, freshMod([4]float64{5, 5, 5, 5}), phyModView{}) + d := phyDisplayFrom(cable, cableRange{}, false, freshMod([4]float64{5, 5, 5, 5}), phyModView{}) if d.haveSNR { t.Error("one silent module should withhold snr") } @@ -93,6 +93,36 @@ func TestCableMetresString(t *testing.T) { } } +func TestCableRange(t *testing.T) { + var r cableRange + for _, m := range []int{42, 40, 44, 41} { + r.add(m) + } + if r.String() != "40-44" { + t.Errorf("range = %q, want 40-44", r.String()) + } + one := cableRange{} + one.add(42) + if one.String() != "42" { + t.Errorf("single measure = %q, want 42", one.String()) + } + + cable := cableInfo{ + ecd: ecdResult{verdicts: [4]int{pairOK, pairOK, pairOK, pairOK}, metres: [4]int{45, 45, 41, 46}}, + maps: [2]byte{pairIdentityMap, pairIdentityMap}, + haveMaps: [2]bool{true, true}, + } + d := phyDisplayFrom(cable, r, false, freshMod([4]float64{5, 5, 5, 5}), freshMod([4]float64{5, 5, 5, 5})) + if d.metres != "40-44" || d.metresClass != clsGood { + t.Errorf("metres = %q class %d, want the range shown good", d.metres, d.metresClass) + } + faulted := cableInfo{ecd: ecdResult{verdicts: [4]int{pairOK, pairOpen, pairOK, pairOK}}} + d = phyDisplayFrom(faulted, r, false, freshMod([4]float64{5, 5, 5, 5}), freshMod([4]float64{5, 5, 5, 5})) + if d.metres != "OPEN" { + t.Errorf("fault shows %q, want the verdict to beat the range", d.metres) + } +} + func TestSNRClass(t *testing.T) { for _, c := range []struct { margin float64 diff --git a/ui.go b/ui.go index 0ce2b2b..a489814 100644 --- a/ui.go +++ b/ui.go @@ -70,7 +70,7 @@ const ( cellGapX = 3 rowHMin = 46 - btnW = 220 + btnW = 190 btnH = 72 btnRadius = 10 btnBorder = 2 @@ -124,6 +124,9 @@ type display struct { holdFrac float64 fired bool + autoBtn rect + autoDown bool + version string versionSpot rect showVersion bool @@ -186,6 +189,7 @@ func (d *display) layout(w, h int) error { d.headerH = headerTop + ringD + 24 colHdrH := d.small.lineH + 18 d.resetBtn = rect{w - uiMargin - btnW, h - uiMargin - btnH, btnW, btnH} + d.autoBtn = rect{d.resetBtn.x - uiColGap - btnW, d.resetBtn.y, btnW, btnH} // Rows stretch to spend the whole band between the column headers and the // footer, cells riding along at their design proportion of the row. @@ -229,33 +233,26 @@ func (d *display) holdReset(x, y int, down bool, now time.Time) bool { return true } -// Accent rather than the status colours because it is something to press, not -// something being reported. -func (d *display) drawResetButton() { - r := d.resetBtn - d.fb.roundRect(r.x, r.y, r.w, r.h, btnRadius, uiAccent) +func (d *display) fillButton(r rect, frac float64, label string, frame, ink rgb) { + d.fb.roundRect(r.x, r.y, r.w, r.h, btnRadius, frame) d.fb.roundRect(r.x+btnBorder, r.y+btnBorder, r.w-2*btnBorder, r.h-2*btnBorder, btnRadius-btnBorder, uiBg) split := r.x + btnBorder - if d.holdFrac > 0 { - w := int(float64(r.w-2*btnBorder) * math.Min(d.holdFrac, 1)) + if frac > 0 { + w := int(float64(r.w-2*btnBorder) * math.Min(frac, 1)) d.fb.roundRect(r.x+btnBorder, r.y+btnBorder, w, r.h-2*btnBorder, - btnRadius-btnBorder, uiAccent) + btnRadius-btnBorder, frame) split += w } - label, base := "RESET", uiAccent - if d.showVersion { - label, base = d.version, uiMuted - } lx := r.x + (r.w-len(label)*d.textB.cellW)/2 ly := r.y + (r.h-d.textB.lineH)/2 - d.textB.capTop // The label straddles the fill, so each glyph takes the colour that reads // against whatever is behind it. for i, c := range label { gx := lx + i*d.textB.cellW - col := base + col := ink if gx+d.textB.cellW/2 < split { col = uiBg } @@ -263,6 +260,32 @@ func (d *display) drawResetButton() { } } +// Accent rather than the status colours because it is something to press, not +// something being reported. +func (d *display) drawResetButton() { + label, ink := "RESET", uiAccent + if d.showVersion { + label, ink = d.version, uiMuted + } + d.fillButton(d.resetBtn, d.holdFrac, label, uiAccent, ink) +} + +// Muted until switched on; the fill is the time already spent toward the next +// automatic measure. +func (d *display) drawAutoButton(on bool, frac float64) { + c := uiMuted + if on { + c = uiAccent + } + d.fillButton(d.autoBtn, frac, "AUTO", c, c) +} + +func (d *display) tapAuto(x, y int, down bool) bool { + was := d.autoDown + d.autoDown = down + return down && !was && d.autoBtn.contains(x, y) +} + // go run never stamps VCS info, so dev builds have no revision to show. func buildVersion() string { info, ok := debug.ReadBuildInfo() @@ -574,7 +597,7 @@ func (d *display) drawFooter(v view, elapsed time.Duration) { } func (d *display) render(v view, elapsed time.Duration, phy phyDisplay, - measuring bool, hv histView, target float64) error { + measuring bool, hv histView, target float64, autoOn bool, autoFrac float64) error { fb := d.fb fb.fill(uiBg) @@ -583,6 +606,7 @@ func (d *display) render(v view, elapsed time.Duration, phy phyDisplay, d.drawColHeaders() d.drawRows(v, phy, measuring, hv, target) d.drawFooter(v, elapsed) + d.drawAutoButton(autoOn, autoFrac) d.drawResetButton() return fb.flush() }