Render the live status to the framebuffer in Atkinson Hyperlegible Mono

This commit is contained in:
flamingcow
2026-07-25 19:41:53 -07:00
parent 16416bc1b0
commit 419bdc4bb7
8 changed files with 506 additions and 24 deletions
+168
View File
@@ -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)<<fb.rShift | uint32(c.g)<<fb.gShift | uint32(c.b)<<fb.bShift
}
type rgb struct {
r, g, b uint8
}
func (fb *framebuffer) fill(c rgb) {
v := fb.pixel(c)
row := make([]byte, fb.stride)
for x := 0; x+4 <= fb.stride; x += 4 {
row[x+0] = byte(v)
row[x+1] = byte(v >> 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)
}
Binary file not shown.
Binary file not shown.
+5 -1
View File
@@ -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
)
+4
View File
@@ -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=
+56 -23
View File
@@ -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)
}
}
}
+108
View File
@@ -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
}
+165
View File
@@ -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))
}