hdmistat/internal/fbdraw/display.go
2025-07-24 14:32:50 +02:00

310 lines
6.4 KiB
Go

package fbdraw
import (
"fmt"
"log"
"os"
"syscall"
"unsafe"
)
// FBDisplay renders to a Linux framebuffer device
type FBDisplay struct {
device string
file *os.File
info fbVarScreeninfo
fixInfo fbFixScreeninfo
data []byte
charWidth int
charHeight int
}
// fbFixScreeninfo from linux/fb.h
type fbFixScreeninfo struct {
ID [16]byte
SMEMStart uint64
SMEMLen uint32
Type uint32
TypeAux uint32
Visual uint32
XPanStep uint16
YPanStep uint16
YWrapStep uint16
_ uint16
LineLength uint32
MMIOStart uint64
MMIOLen uint32
Accel uint32
Capabilities uint16
Reserved [2]uint16
}
// fbVarScreeninfo from linux/fb.h
type fbVarScreeninfo struct {
XRes uint32
YRes uint32
XResVirtual uint32
YResVirtual uint32
XOffset uint32
YOffset uint32
BitsPerPixel uint32
Grayscale uint32
Red fbBitfield
Green fbBitfield
Blue fbBitfield
Transp fbBitfield
NonStd uint32
Activate uint32
Height uint32
Width uint32
AccelFlags uint32
PixClock uint32
LeftMargin uint32
RightMargin uint32
UpperMargin uint32
LowerMargin uint32
HSyncLen uint32
VSyncLen uint32
Sync uint32
VMode uint32
Rotate uint32
Colorspace uint32
Reserved [4]uint32
}
type fbBitfield struct {
Offset uint32
Length uint32
Right uint32
}
const (
fbiogetVscreeninfo = 0x4600
fbiogetFscreeninfo = 0x4602
)
// NewFBDisplay creates a display for a specific framebuffer device
func NewFBDisplay(device string) (*FBDisplay, error) {
file, err := os.OpenFile(device, os.O_RDWR, 0) //nolint:gosec
if err != nil {
return nil, fmt.Errorf("opening framebuffer %s: %w", device, err)
}
display := &FBDisplay{
device: device,
file: file,
}
// Get variable screen info
if err := display.getScreenInfo(); err != nil {
_ = file.Close()
return nil, err
}
// Get fixed screen info
if err := display.getFixedInfo(); err != nil {
_ = file.Close()
return nil, err
}
// Memory map the framebuffer
size := int(display.fixInfo.SMEMLen)
display.data, err = syscall.Mmap(
int(file.Fd()),
0,
size,
syscall.PROT_READ|syscall.PROT_WRITE,
syscall.MAP_SHARED,
)
if err != nil {
_ = file.Close()
return nil, fmt.Errorf("mmap framebuffer: %w", err)
}
// Calculate character dimensions (rough approximation)
display.charWidth = 8
display.charHeight = 16
return display, nil
}
// NewFBDisplayAuto auto-detects and opens the framebuffer
func NewFBDisplayAuto() (*FBDisplay, error) {
// Try common framebuffer devices
devices := []string{"/dev/fb0", "/dev/fb1", "/dev/graphics/fb0"}
for _, device := range devices {
if _, err := os.Stat(device); err == nil {
display, err := NewFBDisplay(device)
if err == nil {
return display, nil
}
}
}
return nil, fmt.Errorf("no framebuffer device found")
}
func (d *FBDisplay) getScreenInfo() error {
_, _, errno := syscall.Syscall(
syscall.SYS_IOCTL,
d.file.Fd(),
fbiogetVscreeninfo,
uintptr(unsafe.Pointer(&d.info)), //nolint:gosec
)
if errno != 0 {
return fmt.Errorf("ioctl FBIOGET_VSCREENINFO: %w", errno)
}
return nil
}
func (d *FBDisplay) getFixedInfo() error {
_, _, errno := syscall.Syscall(
syscall.SYS_IOCTL,
d.file.Fd(),
fbiogetFscreeninfo,
uintptr(unsafe.Pointer(&d.fixInfo)), //nolint:gosec
)
if errno != 0 {
return fmt.Errorf("ioctl FBIOGET_FSCREENINFO: %w", errno)
}
return nil
}
// Write renders a grid to the framebuffer
func (d *FBDisplay) Write(grid *CharGrid) error {
// Render grid to image
img, err := grid.Render()
if err != nil {
return fmt.Errorf("rendering grid: %w", err)
}
// Clear framebuffer
for i := range d.data {
d.data[i] = 0
}
// Copy image to framebuffer
bounds := img.Bounds()
bytesPerPixel := int(d.info.BitsPerPixel / 8)
lineLength := int(d.fixInfo.LineLength)
for y := bounds.Min.Y; y < bounds.Max.Y && y < int(d.info.YRes); y++ {
for x := bounds.Min.X; x < bounds.Max.X && x < int(d.info.XRes); x++ {
r, g, b, _ := img.At(x, y).RGBA()
offset := y*lineLength + x*bytesPerPixel
if offset+bytesPerPixel <= len(d.data) {
// Assuming 32-bit BGRA format (most common)
if bytesPerPixel == 4 {
d.data[offset+0] = byte(b >> 8)
d.data[offset+1] = byte(g >> 8)
d.data[offset+2] = byte(r >> 8)
d.data[offset+3] = 0xFF
}
}
}
}
return nil
}
// Size returns the display size in characters
func (d *FBDisplay) Size() (width, height int) {
width = int(d.info.XRes) / d.charWidth
height = int(d.info.YRes) / d.charHeight
return
}
// Close closes the framebuffer
func (d *FBDisplay) Close() error {
if d.data != nil {
if err := syscall.Munmap(d.data); err != nil {
log.Printf("munmap error: %v", err)
}
d.data = nil
}
if d.file != nil {
if err := d.file.Close(); err != nil {
return err
}
d.file = nil
}
return nil
}
// TerminalDisplay renders to the terminal using ANSI escape codes
type TerminalDisplay struct {
width int
height int
}
// NewTerminalDisplay creates a terminal display
func NewTerminalDisplay(width, height int) *TerminalDisplay {
return &TerminalDisplay{
width: width,
height: height,
}
}
// Write renders a grid to the terminal
func (d *TerminalDisplay) Write(grid *CharGrid) error {
// Clear screen
fmt.Print("\033[2J\033[H")
// Print ANSI representation
fmt.Print(grid.ToANSI())
return nil
}
// Size returns the terminal size in characters
func (d *TerminalDisplay) Size() (width, height int) {
return d.width, d.height
}
// Close is a no-op for terminal display
func (d *TerminalDisplay) Close() error {
// Clear screen one last time
fmt.Print("\033[2J\033[H")
return nil
}
// LogDisplay renders to a logger for debugging
type LogDisplay struct {
width int
height int
logger *log.Logger
}
// NewLogDisplay creates a log display
func NewLogDisplay(width, height int, logger *log.Logger) *LogDisplay {
if logger == nil {
logger = log.New(os.Stderr, "[fbdraw] ", log.LstdFlags)
}
return &LogDisplay{
width: width,
height: height,
logger: logger,
}
}
// Write logs the grid as text
func (d *LogDisplay) Write(grid *CharGrid) error {
d.logger.Printf("=== Frame ===\n%s\n", grid.ToText())
return nil
}
// Size returns the display size
func (d *LogDisplay) Size() (width, height int) {
return d.width, d.height
}
// Close is a no-op for log display
func (d *LogDisplay) Close() error {
return nil
}