2026-07-25 19:41:53 -07:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
2026-07-25 21:41:21 -07:00
|
|
|
"math"
|
2026-07-25 19:41:53 -07:00
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
uiBg = rgb{0x12, 0x14, 0x18}
|
|
|
|
|
uiPanel = rgb{0x1c, 0x20, 0x26}
|
2026-07-25 19:48:15 -07:00
|
|
|
uiRule = rgb{0x2e, 0x34, 0x3c}
|
2026-07-25 21:41:21 -07:00
|
|
|
uiButton = rgb{0x25, 0x2b, 0x33}
|
2026-07-25 19:41:53 -07:00
|
|
|
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}
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-25 19:48:15 -07:00
|
|
|
// 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
|
2026-07-25 21:41:21 -07:00
|
|
|
|
|
|
|
|
btnW = 200
|
|
|
|
|
btnH = 64
|
|
|
|
|
holdDuration = time.Second
|
2026-07-25 19:48:15 -07:00
|
|
|
)
|
|
|
|
|
|
2026-07-25 21:41:21 -07:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 19:41:53 -07:00
|
|
|
type display struct {
|
2026-07-25 19:48:15 -07:00
|
|
|
fb *framebuffer
|
|
|
|
|
huge *textFace
|
|
|
|
|
grid *textFace
|
|
|
|
|
gridB *textFace
|
|
|
|
|
small *textFace
|
2026-07-25 21:41:21 -07:00
|
|
|
|
|
|
|
|
resetBtn rect
|
|
|
|
|
holdStart time.Time
|
|
|
|
|
holdFrac float64
|
|
|
|
|
fired bool
|
2026-07-25 19:41:53 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newDisplay() (*display, error) {
|
|
|
|
|
fb, err := openFramebuffer("/dev/fb0")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-07-25 19:48:15 -07:00
|
|
|
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
|
2026-07-25 19:41:53 -07:00
|
|
|
}
|
2026-07-25 19:48:15 -07:00
|
|
|
if d.grid.cellW != d.gridB.cellW {
|
2026-07-25 19:41:53 -07:00
|
|
|
fb.close()
|
2026-07-25 19:48:15 -07:00
|
|
|
return nil, fmt.Errorf("grid faces disagree on cell width: %d vs %d",
|
|
|
|
|
d.grid.cellW, d.gridB.cellW)
|
2026-07-25 19:41:53 -07:00
|
|
|
}
|
2026-07-25 21:41:21 -07:00
|
|
|
d.resetBtn = rect{
|
|
|
|
|
x: fb.w - uiMargin - btnW,
|
|
|
|
|
y: fb.h - uiMargin - btnH,
|
|
|
|
|
w: btnW,
|
|
|
|
|
h: btnH,
|
|
|
|
|
}
|
2026-07-25 19:48:15 -07:00
|
|
|
return d, nil
|
2026-07-25 19:41:53 -07:00
|
|
|
}
|
|
|
|
|
|
2026-07-25 21:41:21 -07:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 19:41:53 -07:00
|
|
|
func (d *display) close() {
|
|
|
|
|
d.fb.close()
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 19:48:15 -07:00
|
|
|
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)
|
2026-07-25 19:41:53 -07:00
|
|
|
}
|
|
|
|
|
|
2026-07-25 19:48:15 -07:00
|
|
|
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)
|
2026-07-25 19:41:53 -07:00
|
|
|
}
|
|
|
|
|
|
2026-07-25 21:30:37 -07:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 19:41:53 -07:00
|
|
|
func errColor(n uint64) rgb {
|
|
|
|
|
if n == 0 {
|
|
|
|
|
return uiGreen
|
|
|
|
|
}
|
|
|
|
|
return uiRed
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 19:48:15 -07:00
|
|
|
func rateColor(gb, target float64) rgb {
|
2026-07-25 19:41:53 -07:00
|
|
|
switch {
|
|
|
|
|
case gb >= target*rateGreenFrac:
|
|
|
|
|
return uiGreen
|
|
|
|
|
case gb >= target*rateYellowFrac:
|
|
|
|
|
return uiYellow
|
|
|
|
|
default:
|
|
|
|
|
return uiRed
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 22:38:52 -07:00
|
|
|
func (d *display) render(dirs []*direction, views []view, elapsed time.Duration, target float64, cable string) {
|
2026-07-25 19:41:53 -07:00
|
|
|
fb := d.fb
|
|
|
|
|
fb.fill(uiBg)
|
|
|
|
|
|
2026-07-25 19:48:15 -07:00
|
|
|
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)
|
2026-07-25 19:41:53 -07:00
|
|
|
|
|
|
|
|
var total uint64
|
|
|
|
|
for _, v := range views {
|
|
|
|
|
total += v.errors
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 19:48:15 -07:00
|
|
|
bandY, bandH := 40, 150
|
2026-07-25 19:41:53 -07:00
|
|
|
fb.rect(0, bandY, fb.w, bandH, uiPanel)
|
2026-07-25 19:48:15 -07:00
|
|
|
fb.rect(0, bandY, 8, bandH, errColor(total))
|
2026-07-25 19:41:53 -07:00
|
|
|
word, wc := "NO ERRORS", uiGreen
|
|
|
|
|
if total > 0 {
|
|
|
|
|
word, wc = fmt.Sprintf("%s ERRORS", commas(total)), uiRed
|
|
|
|
|
}
|
2026-07-25 19:48:15 -07:00
|
|
|
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
|
2026-07-25 21:41:21 -07:00
|
|
|
avail := fb.h - (bandY + bandH) - btnH - 2*uiMargin
|
2026-07-25 19:48:15 -07:00
|
|
|
y := bandY + bandH + 8 + (avail-2*sectionH-gap)/2
|
|
|
|
|
|
2026-07-25 22:38:52 -07:00
|
|
|
y = d.section(y, "RATE", "")
|
2026-07-25 19:48:15 -07:00
|
|
|
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
|
2026-07-25 19:41:53 -07:00
|
|
|
for i, dir := range dirs {
|
2026-07-25 19:48:15 -07:00
|
|
|
d.dirTag(dir, y)
|
|
|
|
|
v := views[i]
|
|
|
|
|
d.rightAt(colTxGbEnd, y, fmt.Sprintf("%.2f", v.txGbps), rateColor(v.txGbps, target))
|
2026-07-25 21:30:37 -07:00
|
|
|
d.rightAt(colTxPPSEnd, y, commas(roundPPS(v.txPPS)), uiFg)
|
2026-07-25 19:48:15 -07:00
|
|
|
d.rightAt(colRxGbEnd, y, fmt.Sprintf("%.2f", v.rxGbps), rateColor(v.rxGbps, target))
|
2026-07-25 21:30:37 -07:00
|
|
|
d.rightAt(colRxPPSEnd, y, commas(roundPPS(v.rxPPS)), uiFg)
|
2026-07-25 19:48:15 -07:00
|
|
|
y += lineH
|
2026-07-25 19:41:53 -07:00
|
|
|
}
|
|
|
|
|
|
2026-07-25 19:48:15 -07:00
|
|
|
y += gap
|
2026-07-25 22:38:52 -07:00
|
|
|
y = d.section(y, "OVERALL", cable)
|
2026-07-25 19:48:15 -07:00
|
|
|
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
|
2026-07-25 19:41:53 -07:00
|
|
|
}
|
2026-07-25 19:48:15 -07:00
|
|
|
|
2026-07-25 21:41:21 -07:00
|
|
|
d.drawResetButton()
|
2026-07-25 19:48:15 -07:00
|
|
|
fb.flush()
|
2026-07-25 19:41:53 -07:00
|
|
|
}
|
|
|
|
|
|
2026-07-25 22:38:52 -07:00
|
|
|
func (d *display) section(y int, title, right string) int {
|
2026-07-25 19:48:15 -07:00
|
|
|
d.small.draw(d.fb, uiMargin, y, title, uiFg)
|
2026-07-25 22:38:52 -07:00
|
|
|
if right != "" {
|
|
|
|
|
d.right(d.small, d.cellX(colKdropEnd), y, right, uiCyan)
|
|
|
|
|
}
|
2026-07-25 19:48:15 -07:00
|
|
|
ruleY := y + d.small.cellH + 3
|
|
|
|
|
d.fb.rect(uiMargin, ruleY, d.fb.w-2*uiMargin, 1, uiRule)
|
|
|
|
|
return ruleY + 7
|
2026-07-25 19:41:53 -07:00
|
|
|
}
|
|
|
|
|
|
2026-07-25 19:48:15 -07:00
|
|
|
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)
|
2026-07-25 19:41:53 -07:00
|
|
|
}
|
|
|
|
|
|
2026-07-25 19:48:15 -07:00
|
|
|
// 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)
|
|
|
|
|
}
|
2026-07-25 19:41:53 -07:00
|
|
|
}
|