package main import ( "fmt" "math" "time" ) var ( uiBg = rgb{0x12, 0x14, 0x18} uiPanel = rgb{0x1c, 0x20, 0x26} uiRule = rgb{0x2e, 0x34, 0x3c} uiButton = rgb{0x25, 0x2b, 0x33} uiFg = rgb{0xe6, 0xe8, 0xea} uiDim = rgb{0x7a, 0x82, 0x8c} uiCyan = rgb{0x5c, 0xc8, 0xe0} uiGreen = rgb{0x4c, 0xc2, 0x6a} uiRed = rgb{0xe0, 0x4b, 0x4b} uiYellow = rgb{0xe0, 0xb0, 0x40} ) // Everything tabular sits on one monospace cell grid and every number is // right-aligned to a column end, so columns line up by construction. const ( uiMargin = 16 cellDirA = 0 cellArrow = 2 cellDirB = 4 colTxGbEnd = 14 colTxPPSEnd = 27 colRxGbEnd = 37 colRxPPSEnd = 50 colFramesEnd = 18 colDataEnd = 27 colLostEnd = 38 colLateEnd = 46 colCRCEnd = 53 colKdropEnd = 62 btnW = 200 btnH = 64 holdDuration = time.Second ) type rect struct { x, y, w, h int } func (r rect) contains(x, y int) bool { return x >= r.x && x < r.x+r.w && y >= r.y && y < r.y+r.h } type display struct { fb *framebuffer huge *textFace grid *textFace gridB *textFace small *textFace resetBtn rect holdStart time.Time holdFrac float64 fired bool } func newDisplay() (*display, error) { fb, err := openFramebuffer("/dev/fb0") if err != nil { return nil, err } d := &display{fb: fb} for _, spec := range []struct { dst **textFace bold bool size float64 }{ {&d.huge, true, 72}, {&d.grid, false, 24}, {&d.gridB, true, 24}, {&d.small, false, 18}, } { face, err := loadFace(spec.bold, spec.size) if err != nil { fb.close() return nil, err } *spec.dst = face } if d.grid.cellW != d.gridB.cellW { fb.close() return nil, fmt.Errorf("grid faces disagree on cell width: %d vs %d", d.grid.cellW, d.gridB.cellW) } d.resetBtn = rect{ x: fb.w - uiMargin - btnW, y: fb.h - uiMargin - btnH, w: btnW, h: btnH, } return d, nil } // Tracks a press and hold on the reset button, returning true once it has been // held long enough. Lifting or sliding off cancels, and the press has to be // released before it can arm again. func (d *display) holdReset(x, y int, down bool, now time.Time) bool { if !down { d.holdStart, d.holdFrac, d.fired = time.Time{}, 0, false return false } if d.fired || !d.resetBtn.contains(x, y) { d.holdStart, d.holdFrac = time.Time{}, 0 return false } if d.holdStart.IsZero() { d.holdStart = now } d.holdFrac = now.Sub(d.holdStart).Seconds() / holdDuration.Seconds() if d.holdFrac < 1 { return false } d.holdFrac, d.fired, d.holdStart = 0, true, time.Time{} return true } func (d *display) drawResetButton() { r := d.resetBtn d.fb.rect(r.x, r.y, r.w, r.h, uiButton) if d.holdFrac > 0 { w := int(float64(r.w) * math.Min(d.holdFrac, 1)) d.fb.rect(r.x, r.y, w, r.h, uiCyan) } for _, e := range []rect{ {r.x, r.y, r.w, 2}, {r.x, r.y + r.h - 2, r.w, 2}, {r.x, r.y, 2, r.h}, {r.x + r.w - 2, r.y, 2, r.h}, } { d.fb.rect(e.x, e.y, e.w, e.h, uiCyan) } label := "RESET" lx := r.x + (r.w-len(label)*d.gridB.cellW)/2 ly := r.y + (r.h-d.gridB.cellH)/2 // The label straddles the fill, so each glyph takes the colour that reads // against whatever is behind it. split := r.x + int(float64(r.w)*math.Min(d.holdFrac, 1)) for i, c := range label { gx := lx + i*d.gridB.cellW col := uiFg if gx+d.gridB.cellW/2 < split { col = uiBg } d.gridB.draw(d.fb, gx, ly, string(c), col) } } func (d *display) close() { d.fb.close() } func (d *display) cellX(c int) int { return uiMargin + c*d.grid.cellW } func (d *display) at(c, y int, s string, col rgb) { d.grid.draw(d.fb, d.cellX(c), y, s, col) } func (d *display) atBold(c, y int, s string, col rgb) { d.gridB.draw(d.fb, d.cellX(c), y, s, col) } func (d *display) rightAt(cEnd, y int, s string, col rgb) { d.grid.draw(d.fb, d.cellX(cEnd)-len([]rune(s))*d.grid.cellW, y, s, col) } func (d *display) right(f *textFace, xEnd, y int, s string, col rgb) { f.draw(d.fb, xEnd-len([]rune(s))*f.cellW, y, s, col) } func (d *display) center(f *textFace, y int, s string, col rgb) { f.draw(d.fb, (d.fb.w-len([]rune(s))*f.cellW)/2, y, s, col) } // Rendered rates are quantised so the text only changes when the value moves // meaningfully. Without this the low digits churn every frame no matter how // long the averaging window is. func roundPPS(v float64) uint64 { const unit = 1000 if v < 0 { return 0 } return uint64((v+unit/2)/unit) * unit } func errColor(n uint64) rgb { if n == 0 { return uiGreen } return uiRed } func rateColor(gb, target float64) rgb { switch { case gb >= target*rateGreenFrac: return uiGreen case gb >= target*rateYellowFrac: return uiYellow default: return uiRed } } func (d *display) render(dirs []*direction, views []view, elapsed time.Duration, target float64, cable string) { fb := d.fb fb.fill(uiBg) d.small.draw(fb, uiMargin, 10, "cabletest", uiCyan) d.small.draw(fb, uiMargin+11*d.small.cellW, 10, fmt.Sprintf("%s %s %s %s", dirs[0].tx.tag, dirs[0].tx.name, dirs[0].rx.tag, dirs[0].rx.name), uiDim) d.right(d.small, fb.w-uiMargin, 10, uptime(elapsed), uiDim) var total uint64 for _, v := range views { total += v.errors } bandY, bandH := 40, 150 fb.rect(0, bandY, fb.w, bandH, uiPanel) fb.rect(0, bandY, 8, bandH, errColor(total)) word, wc := "NO ERRORS", uiGreen if total > 0 { word, wc = fmt.Sprintf("%s ERRORS", commas(total)), uiRed } d.center(d.huge, bandY+(bandH-d.huge.cellH)/2, word, wc) // Two tight blocks rather than rows spread over the whole panel, centred in // what is left below the band. lineH := d.grid.cellH + 4 sectionH := d.small.cellH + 10 + (1+len(dirs))*lineH gap := 30 avail := fb.h - (bandY + bandH) - btnH - 2*uiMargin y := bandY + bandH + 8 + (avail-2*sectionH-gap)/2 y = d.section(y, "RATE", "") d.rightAt(colTxGbEnd, y, "TX Gb/s", uiDim) d.rightAt(colTxPPSEnd, y, "TX pps", uiDim) d.rightAt(colRxGbEnd, y, "RX Gb/s", uiDim) d.rightAt(colRxPPSEnd, y, "RX pps", uiDim) y += lineH for i, dir := range dirs { d.dirTag(dir, y) v := views[i] d.rightAt(colTxGbEnd, y, fmt.Sprintf("%.2f", v.txGbps), rateColor(v.txGbps, target)) d.rightAt(colTxPPSEnd, y, commas(roundPPS(v.txPPS)), uiFg) d.rightAt(colRxGbEnd, y, fmt.Sprintf("%.2f", v.rxGbps), rateColor(v.rxGbps, target)) d.rightAt(colRxPPSEnd, y, commas(roundPPS(v.rxPPS)), uiFg) y += lineH } y += gap y = d.section(y, "OVERALL", cable) d.rightAt(colFramesEnd, y, "frames", uiDim) d.rightAt(colDataEnd, y, "data", uiDim) d.rightAt(colLostEnd, y, "lost", uiDim) d.rightAt(colLateEnd, y, "late", uiDim) d.rightAt(colCRCEnd, y, "crc", uiDim) d.rightAt(colKdropEnd, y, "kdrop", uiDim) y += lineH for i, dir := range dirs { d.dirTag(dir, y) v := views[i] d.rightAt(colFramesEnd, y, commas(v.txFrames), uiFg) d.rightAt(colDataEnd, y, humanBytes(v.txSent), uiFg) d.rightAt(colLostEnd, y, commas(v.lost), errColor(v.lost)) d.rightAt(colLateEnd, y, commas(v.late), errColor(v.late)) d.rightAt(colCRCEnd, y, commas(v.crc), errColor(v.crc)) d.rightAt(colKdropEnd, y, commas(v.kdrop), errColor(v.kdrop)) y += lineH } d.drawResetButton() fb.flush() } func (d *display) section(y int, title, right string) int { d.small.draw(d.fb, uiMargin, y, title, uiFg) if right != "" { d.right(d.small, d.cellX(colKdropEnd), y, right, uiCyan) } ruleY := y + d.small.cellH + 3 d.fb.rect(uiMargin, ruleY, d.fb.w-2*uiMargin, 1, uiRule) return ruleY + 7 } func (d *display) dirTag(dir *direction, y int) { d.atBold(cellDirA, y, dir.tx.tag, uiCyan) d.arrow(d.cellX(cellArrow), y+d.grid.cellH/2, uiDim) d.atBold(cellDirB, y, dir.rx.tag, uiCyan) } // Drawn because the font has no arrow glyph. Occupies exactly one cell so the // grid is unaffected. func (d *display) arrow(x, y int, c rgb) { w := d.grid.cellW d.fb.rect(x, y-1, w-5, 2, c) for i := 0; i < 6; i++ { d.fb.rect(x+w-6+i, y-5+i, 1, 11-2*i, c) } }