diff --git a/fb.go b/fb.go new file mode 100644 index 0000000..04d65f4 --- /dev/null +++ b/fb.go @@ -0,0 +1,168 @@ +package main + +import ( + "fmt" + "os" + "unsafe" + + "golang.org/x/sys/unix" +) + +const ( + fbioGetVScreenInfo = 0x4600 + fbioBlank = 0x4611 + fbBlankUnblank = 0 +) + +// The variable screen info is a flat run of 40 u32s, so it is read as an array +// rather than a struct to sidestep any question of padding. +const ( + viXres = 0 + viYres = 1 + viBitsPerPixel = 6 + viRedOffset = 8 + viGreenOffset = 11 + viBlueOffset = 14 + viScreenInfoLen = 40 +) + +type framebuffer struct { + file *os.File + mem []byte + back []byte + w int + h int + stride int + rShift uint + gShift uint + bShift uint +} + +func openFramebuffer(path string) (*framebuffer, error) { + f, err := os.OpenFile(path, os.O_RDWR, 0) + if err != nil { + return nil, err + } + + var vi [viScreenInfoLen]uint32 + if _, _, errno := unix.Syscall(unix.SYS_IOCTL, f.Fd(), + fbioGetVScreenInfo, uintptr(unsafe.Pointer(&vi[0]))); errno != 0 { + f.Close() + return nil, fmt.Errorf("get screen info: %w", errno) + } + if vi[viBitsPerPixel] != 32 { + f.Close() + return nil, fmt.Errorf("only 32bpp supported, got %d", vi[viBitsPerPixel]) + } + + // Wake the panel; the console blanks it after a timeout. + unix.Syscall(unix.SYS_IOCTL, f.Fd(), fbioBlank, fbBlankUnblank) + + stride, ok := readUint("/sys/class/graphics/fb0/stride") + if !ok { + f.Close() + return nil, fmt.Errorf("cannot read framebuffer stride") + } + + fb := &framebuffer{ + file: f, + w: int(vi[viXres]), + h: int(vi[viYres]), + stride: int(stride), + rShift: uint(vi[viRedOffset]), + gShift: uint(vi[viGreenOffset]), + bShift: uint(vi[viBlueOffset]), + } + + fb.mem, err = unix.Mmap(int(f.Fd()), 0, fb.stride*fb.h, + unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED) + if err != nil { + f.Close() + return nil, fmt.Errorf("mmap: %w", err) + } + fb.back = make([]byte, fb.stride*fb.h) + return fb, nil +} + +func (fb *framebuffer) close() { + unix.Munmap(fb.mem) + fb.file.Close() +} + +func (fb *framebuffer) pixel(c rgb) uint32 { + return uint32(c.r)<> 8) + row[x+2] = byte(v >> 16) + row[x+3] = byte(v >> 24) + } + for y := 0; y < fb.h; y++ { + copy(fb.back[y*fb.stride:], row) + } +} + +func (fb *framebuffer) rect(x0, y0, w, h int, c rgb) { + v := fb.pixel(c) + for y := y0; y < y0+h; y++ { + if y < 0 || y >= fb.h { + continue + } + base := y * fb.stride + for x := x0; x < x0+w; x++ { + if x < 0 || x >= fb.w { + continue + } + o := base + x*4 + fb.back[o+0] = byte(v) + fb.back[o+1] = byte(v >> 8) + fb.back[o+2] = byte(v >> 16) + fb.back[o+3] = byte(v >> 24) + } + } +} + +// Blends src over the existing pixel, with cov as 0-255 coverage. +func (fb *framebuffer) blend(x, y int, c rgb, cov uint8) { + if x < 0 || y < 0 || x >= fb.w || y >= fb.h || cov == 0 { + return + } + o := y*fb.stride + x*4 + if cov == 255 { + v := fb.pixel(c) + fb.back[o+0] = byte(v) + fb.back[o+1] = byte(v >> 8) + fb.back[o+2] = byte(v >> 16) + fb.back[o+3] = byte(v >> 24) + return + } + a := uint32(cov) + old := uint32(fb.back[o+0]) | uint32(fb.back[o+1])<<8 | + uint32(fb.back[o+2])<<16 | uint32(fb.back[o+3])<<24 + orr := uint8(old >> fb.rShift) + og := uint8(old >> fb.gShift) + ob := uint8(old >> fb.bShift) + mix := rgb{ + r: uint8((uint32(c.r)*a + uint32(orr)*(255-a)) / 255), + g: uint8((uint32(c.g)*a + uint32(og)*(255-a)) / 255), + b: uint8((uint32(c.b)*a + uint32(ob)*(255-a)) / 255), + } + v := fb.pixel(mix) + fb.back[o+0] = byte(v) + fb.back[o+1] = byte(v >> 8) + fb.back[o+2] = byte(v >> 16) + fb.back[o+3] = byte(v >> 24) +} + +func (fb *framebuffer) flush() { + copy(fb.mem, fb.back) +} diff --git a/font/AtkinsonHyperlegibleMono-Bold.ttf b/font/AtkinsonHyperlegibleMono-Bold.ttf new file mode 100644 index 0000000..0cd144b Binary files /dev/null and b/font/AtkinsonHyperlegibleMono-Bold.ttf differ diff --git a/font/AtkinsonHyperlegibleMono-Regular.ttf b/font/AtkinsonHyperlegibleMono-Regular.ttf new file mode 100644 index 0000000..a55ce8b Binary files /dev/null and b/font/AtkinsonHyperlegibleMono-Regular.ttf differ diff --git a/go.mod b/go.mod index de6d7c1..3ba61d6 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,8 @@ module g.fc.run/theater/cabletest go 1.26.5 -require golang.org/x/sys v0.47.0 // indirect +require ( + golang.org/x/image v0.44.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect +) diff --git a/go.sum b/go.sum index 37ee2d4..34397dd 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,6 @@ +golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= +golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= diff --git a/main.go b/main.go index f7cba87..124eceb 100644 --- a/main.go +++ b/main.go @@ -156,38 +156,60 @@ var intervalCols = []colSpec{ {title: "ERRORS", width: 11, right: true}, } -func (d *direction) intervalRow(elapsed time.Duration, secs, target float64) []string { +// One interval's numbers, shared by the console table and the framebuffer so +// both always show the same figures. +type view struct { + txPPS, rxPPS float64 + txGbps, rxGbps float64 + txFrames, txSent uint64 + rxFrames, rxGot uint64 + lost, late uint64 + crc, badMagic uint64 + kdrop, errors uint64 +} + +func (d *direction) view(secs float64) view { now := d.snapshot() p := d.prev d.prev = now + d.sampleDrops() txF := now.txFrames - p.txFrames - txB := now.txBytes - p.txBytes rxF := now.rxFrames - p.rxFrames - rxB := now.rxBytes - p.rxBytes - - d.sampleDrops() b := d.errBase - lost := now.lost - b.lost - late := now.late - b.late - crc := now.crcErr - b.crcErr - badMagic := now.badMagic - b.badMagic - badLen := now.badLen - b.badLen - drops := d.drops - d.dropBase + v := view{ + txPPS: float64(txF) / secs, + rxPPS: float64(rxF) / secs, + txGbps: gbps(now.txBytes-p.txBytes, txF, secs), + rxGbps: gbps(now.rxBytes-p.rxBytes, rxF, secs), + txFrames: now.txFrames, + txSent: now.txBytes, + rxFrames: now.rxFrames, + rxGot: now.rxBytes, + lost: now.lost - b.lost, + late: now.late - b.late, + crc: now.crcErr - b.crcErr, + badMagic: now.badMagic - b.badMagic, + kdrop: d.drops - d.dropBase, + } + v.errors = v.lost + v.crc + v.badMagic + (now.badLen - b.badLen) + v.kdrop + return v +} +func (d *direction) row(elapsed time.Duration, v view, target float64) []string { return []string{ uptime(elapsed), paint(d.short, cCyan), - commas(uint64(float64(txF) / secs)), - rateCell(gbps(txB, txF, secs), target), - commas(uint64(float64(rxF) / secs)), - rateCell(gbps(rxB, rxF, secs), target), - statusCell(lost), - statusCell(late), - statusCell(crc), - statusCell(badMagic), - statusCell(drops), - statusCell(lost + crc + badMagic + badLen + drops), + commas(uint64(v.txPPS)), + rateCell(v.txGbps, target), + commas(uint64(v.rxPPS)), + rateCell(v.rxGbps, target), + statusCell(v.lost), + statusCell(v.late), + statusCell(v.crc), + statusCell(v.badMagic), + statusCell(v.kdrop), + statusCell(v.errors), } } @@ -442,6 +464,14 @@ func run(aName, bName, sizesArg, patArg string, space, restoreTerm := watchSpace() defer restoreTerm() + disp, err := newDisplay() + if err != nil { + return fmt.Errorf("display: %w", err) + } + defer disp.close() + disp.config = fmt.Sprintf("%s sizes %s %d streams batch %d crc32c verify", + patterns[patIdx].name, strings.Join(sizeStrs, ","), nStreams, batch) + start := time.Now() close(startTx) tick := time.NewTicker(reportInterval) @@ -466,8 +496,10 @@ func run(aName, bName, sizesArg, patArg string, secs := now.Sub(last).Seconds() last = now elapsed := now.Sub(start) - for _, d := range dirs { - for _, line := range stats.emit(d.intervalRow(elapsed, secs, target)) { + views := make([]view, len(dirs)) + for i, d := range dirs { + views[i] = d.view(secs) + for _, line := range stats.emit(d.row(elapsed, views[i], target)) { fmt.Println(line) } for _, line := range d.reportNIC() { @@ -483,6 +515,7 @@ func run(aName, bName, sizesArg, patArg string, break } } + disp.render(dirs, views, elapsed, target) } } } diff --git a/text.go b/text.go new file mode 100644 index 0000000..7fd2091 --- /dev/null +++ b/text.go @@ -0,0 +1,108 @@ +package main + +import ( + "embed" + "fmt" + "image" + "image/draw" + + "golang.org/x/image/font" + "golang.org/x/image/font/opentype" + "golang.org/x/image/math/fixed" +) + +//go:embed font/AtkinsonHyperlegibleMono-Regular.ttf font/AtkinsonHyperlegibleMono-Bold.ttf +var fontFS embed.FS + +type glyph struct { + // Coverage values, cellW*cellH, origin at the cell's top left. + cov []uint8 + w, h int +} + +type textFace struct { + face font.Face + cellW int + cellH int + ascent int + cache map[rune]*glyph +} + +func loadFace(bold bool, sizePx float64) (*textFace, error) { + name := "font/AtkinsonHyperlegibleMono-Regular.ttf" + if bold { + name = "font/AtkinsonHyperlegibleMono-Bold.ttf" + } + raw, err := fontFS.ReadFile(name) + if err != nil { + return nil, err + } + parsed, err := opentype.Parse(raw) + if err != nil { + return nil, err + } + face, err := opentype.NewFace(parsed, &opentype.FaceOptions{ + Size: sizePx, + DPI: 72, + }) + if err != nil { + return nil, err + } + + m := face.Metrics() + adv, ok := face.GlyphAdvance('0') + if !ok { + return nil, fmt.Errorf("font has no digit glyphs") + } + return &textFace{ + face: face, + cellW: adv.Ceil(), + cellH: (m.Ascent + m.Descent).Ceil(), + ascent: m.Ascent.Ceil(), + cache: make(map[rune]*glyph), + }, nil +} + +// Rasterises once per rune and keeps the coverage mask, since the same few +// dozen runes are redrawn every frame. +func (t *textFace) glyph(r rune) *glyph { + if g, ok := t.cache[r]; ok { + return g + } + g := &glyph{w: t.cellW, h: t.cellH, cov: make([]uint8, t.cellW*t.cellH)} + + dr, mask, maskp, _, ok := t.face.Glyph( + fixed.P(0, t.ascent), r) + if ok { + alpha := image.NewAlpha(image.Rect(0, 0, t.cellW, t.cellH)) + draw.DrawMask(alpha, dr, image.NewUniform(image.White.C), image.Point{}, + mask, maskp, draw.Src) + for y := 0; y < t.cellH; y++ { + for x := 0; x < t.cellW; x++ { + g.cov[y*t.cellW+x] = alpha.AlphaAt(x, y).A + } + } + } + t.cache[r] = g + return g +} + +func (t *textFace) draw(fb *framebuffer, x, y int, s string, c rgb) int { + for _, r := range s { + if r == ' ' { + x += t.cellW + continue + } + g := t.glyph(r) + for gy := 0; gy < g.h; gy++ { + row := gy * g.w + for gx := 0; gx < g.w; gx++ { + if cov := g.cov[row+gx]; cov != 0 { + fb.blend(x+gx, y+gy, c, cov) + } + } + } + x += t.cellW + } + return x +} diff --git a/ui.go b/ui.go new file mode 100644 index 0000000..a6b7cb8 --- /dev/null +++ b/ui.go @@ -0,0 +1,165 @@ +package main + +import ( + "fmt" + "time" +) + +var ( + uiBg = rgb{0x12, 0x14, 0x18} + uiPanel = rgb{0x1c, 0x20, 0x26} + 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} +) + +type display struct { + fb *framebuffer + huge *textFace + big *textFace + small *textFace + config string +} + +func newDisplay() (*display, error) { + fb, err := openFramebuffer("/dev/fb0") + if err != nil { + return nil, err + } + huge, err := loadFace(true, 46) + if err != nil { + fb.close() + return nil, err + } + big, err := loadFace(true, 24) + if err != nil { + fb.close() + return nil, err + } + small, err := loadFace(false, 18) + if err != nil { + fb.close() + return nil, err + } + return &display{fb: fb, huge: huge, big: big, small: small}, nil +} + +func (d *display) close() { + d.fb.close() +} + +func (d *display) right(f *textFace, xEnd, y int, s string, c rgb) { + f.draw(d.fb, xEnd-len([]rune(s))*f.cellW, y, s, c) +} + +func (d *display) center(f *textFace, x0, w, y int, s string, c rgb) { + f.draw(d.fb, x0+(w-len([]rune(s))*f.cellW)/2, y, s, c) +} + +func errColor(n uint64) rgb { + if n == 0 { + return uiGreen + } + return uiRed +} + +func (d *display) 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) { + fb := d.fb + fb.fill(uiBg) + + d.small.draw(fb, 16, 12, "cabletest", uiCyan) + if len(dirs) > 0 { + d.small.draw(fb, 16+11*d.small.cellW, 12, + 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-16, 12, uptime(elapsed), uiDim) + + var total uint64 + for _, v := range views { + total += v.errors + } + + // Status band, readable from across the room. + bandY, bandH := 44, 74 + fb.rect(0, bandY, fb.w, bandH, uiPanel) + fb.rect(0, bandY, 6, bandH, errColor(total)) + word, wc := "NO ERRORS", uiGreen + if total > 0 { + word, wc = fmt.Sprintf("%s ERRORS", commas(total)), uiRed + } + d.center(d.huge, 0, fb.w, bandY+(bandH-d.huge.cellH)/2+2, word, wc) + + panelY := bandY + bandH + 12 + footerH := d.small.cellH + 16 + panelH := (fb.h - panelY - footerH) / max(len(dirs), 1) + for i, dir := range dirs { + d.renderDirection(dir, views[i], 12, panelY+i*panelH, fb.w-24, panelH-10, target) + } + d.footer(fb.h - footerH + 4) + fb.flush() +} + +func (d *display) renderDirection(dir *direction, v view, x, y, w, h int, target float64) { + fb := d.fb + fb.rect(x, y, w, h, uiPanel) + + // The font has no arrow glyph, so the direction marker is drawn. + d.big.draw(fb, x+14, y+12, dir.tx.tag, uiCyan) + d.arrow(x+14+2*d.big.cellW, y+12+d.big.cellH/2, 22, uiDim) + d.big.draw(fb, x+14+2*d.big.cellW+30, y+12, dir.rx.tag, uiCyan) + + col := x + 110 + d.rateBlock(col, y+10, "TX", v.txPPS, v.txGbps, target) + d.rateBlock(col+300, y+10, "RX", v.rxPPS, v.rxGbps, target) + + ex := col + 620 + d.errLine(ex, y+10, "lost", v.lost) + d.errLine(ex, y+10+d.small.cellH+2, "crc", v.crc) + d.errLine(ex, y+10+2*(d.small.cellH+2), "kdrop", v.kdrop) + d.errLine(ex, y+10+3*(d.small.cellH+2), "late", v.late) + + totals := fmt.Sprintf("sent %s frames %s received %s frames %s", + commas(v.txFrames), humanBytes(v.txSent), + commas(v.rxFrames), humanBytes(v.rxGot)) + d.small.draw(fb, x+14, y+h-d.small.cellH-8, totals, uiDim) +} + +func (d *display) arrow(x, y, w int, c rgb) { + d.fb.rect(x, y-1, w-6, 3, c) + for i := 0; i < 7; i++ { + d.fb.rect(x+w-7+i, y-6+i, 1, 13-2*i, c) + } +} + +func (d *display) rateBlock(x, y int, label string, pps, gb, target float64) { + d.small.draw(d.fb, x, y, label, uiDim) + d.big.draw(d.fb, x+3*d.small.cellW, y-2, + fmt.Sprintf("%6.2f Gb/s", gb), d.rateColor(gb, target)) + d.small.draw(d.fb, x+3*d.small.cellW, y+d.big.cellH, + fmt.Sprintf("%s pps", commas(uint64(pps))), uiFg) +} + +func (d *display) footer(y int) { + d.small.draw(d.fb, 16, y, d.config, uiDim) + d.right(d.small, d.fb.w-16, y, "space resets counts", uiDim) +} + +func (d *display) errLine(x, y int, label string, n uint64) { + d.small.draw(d.fb, x, y, label, uiDim) + d.small.draw(d.fb, x+7*d.small.cellW, y, commas(n), errColor(n)) +}