Initial implementation of hdmistat - Linux framebuffer system stats display

Features:
- Beautiful system statistics display using IBM Plex Mono font
- Direct framebuffer rendering without X11/Wayland
- Multiple screens with automatic carousel rotation
- Real-time system monitoring (CPU, memory, disk, network, processes)
- Systemd service integration with install command
- Clean architecture using uber/fx dependency injection

Architecture:
- Cobra CLI with daemon, install, status, and info commands
- Modular design with separate packages for display, rendering, and stats
- Font embedding for zero runtime dependencies
- Layout API for clean text rendering
- Support for multiple screen types (overview, top CPU, top memory)

Technical details:
- Uses gopsutil for cross-platform system stats collection
- Direct Linux framebuffer access via memory mapping
- Anti-aliased text rendering with freetype
- Configurable screen rotation and update intervals
- Structured logging with slog
- Comprehensive test coverage and linting setup

This initial version provides a solid foundation for displaying rich
system information on resource-constrained devices like Raspberry Pis.
This commit is contained in:
2025-07-23 12:55:42 +02:00
commit 402c0797d5
39 changed files with 2160 additions and 0 deletions

View File

@@ -0,0 +1,158 @@
package renderer
import (
"fmt"
"image/color"
"git.eeqj.de/sneak/hdmistat/internal/layout"
"git.eeqj.de/sneak/hdmistat/internal/statcollector"
)
// OverviewScreen displays system overview
type OverviewScreen struct{}
func NewOverviewScreen() *OverviewScreen {
return &OverviewScreen{}
}
func (s *OverviewScreen) Name() string {
return "System Overview"
}
func (s *OverviewScreen) Render(canvas *layout.Canvas, info *statcollector.SystemInfo) error {
width, height := canvas.Size()
// Colors
textColor := color.RGBA{255, 255, 255, 255}
headerColor := color.RGBA{100, 200, 255, 255}
dimColor := color.RGBA{150, 150, 150, 255}
// Styles
titleStyle := layout.TextStyle{Size: 48, Color: headerColor}
headerStyle := layout.TextStyle{Size: 24, Color: headerColor}
normalStyle := layout.TextStyle{Size: 18, Color: textColor}
smallStyle := layout.TextStyle{Size: 16, Color: dimColor}
y := 50
// Title
canvas.DrawText(info.Hostname, layout.Point{X: width / 2, Y: y}, layout.TextStyle{
Size: titleStyle.Size,
Color: titleStyle.Color,
Alignment: layout.AlignCenter,
})
y += 80
// Uptime
uptimeText := fmt.Sprintf("Uptime: %s", layout.FormatDuration(info.Uptime.Seconds()))
canvas.DrawText(uptimeText, layout.Point{X: width / 2, Y: y}, layout.TextStyle{
Size: smallStyle.Size,
Color: smallStyle.Color,
Alignment: layout.AlignCenter,
})
y += 60
// Two column layout
leftX := 50
rightX := width/2 + 50
// Memory section (left)
canvas.DrawText("MEMORY", layout.Point{X: leftX, Y: y}, headerStyle)
y += 35
memUsedPercent := float64(info.MemoryUsed) / float64(info.MemoryTotal) * 100
canvas.DrawText(fmt.Sprintf("Total: %s", layout.FormatBytes(info.MemoryTotal)),
layout.Point{X: leftX, Y: y}, normalStyle)
y += 25
canvas.DrawText(fmt.Sprintf("Used: %s (%.1f%%)", layout.FormatBytes(info.MemoryUsed), memUsedPercent),
layout.Point{X: leftX, Y: y}, normalStyle)
y += 25
canvas.DrawText(fmt.Sprintf("Free: %s", layout.FormatBytes(info.MemoryFree)),
layout.Point{X: leftX, Y: y}, normalStyle)
y += 35
// Memory progress bar
canvas.DrawProgress(leftX, y, 400, 20, memUsedPercent,
color.RGBA{100, 200, 100, 255},
color.RGBA{50, 50, 50, 255})
// CPU section (right)
cpuY := y - 115
canvas.DrawText("CPU", layout.Point{X: rightX, Y: cpuY}, headerStyle)
cpuY += 35
// Show per-core CPU usage
for i, percent := range info.CPUPercent {
if i >= 8 {
// Limit display to 8 cores
canvas.DrawText(fmt.Sprintf("... and %d more cores", len(info.CPUPercent)-8),
layout.Point{X: rightX, Y: cpuY}, smallStyle)
break
}
canvas.DrawText(fmt.Sprintf("Core %d:", i), layout.Point{X: rightX, Y: cpuY}, smallStyle)
canvas.DrawProgress(rightX+80, cpuY-12, 200, 15, percent,
color.RGBA{255, 100, 100, 255},
color.RGBA{50, 50, 50, 255})
cpuY += 20
}
y += 60
// Disk usage section
canvas.DrawText("DISK USAGE", layout.Point{X: leftX, Y: y}, headerStyle)
y += 35
for i, disk := range info.DiskUsage {
if i >= 4 {
break // Limit to 4 disks
}
canvas.DrawText(disk.Path, layout.Point{X: leftX, Y: y}, normalStyle)
usageText := fmt.Sprintf("%s / %s", layout.FormatBytes(disk.Used), layout.FormatBytes(disk.Total))
canvas.DrawText(usageText, layout.Point{X: leftX + 200, Y: y}, smallStyle)
canvas.DrawProgress(leftX+400, y-12, 300, 15, disk.UsedPercent,
color.RGBA{200, 200, 100, 255},
color.RGBA{50, 50, 50, 255})
y += 30
}
y += 30
// Network section
canvas.DrawText("NETWORK", layout.Point{X: leftX, Y: y}, headerStyle)
y += 35
for i, net := range info.Network {
if i >= 3 {
break // Limit to 3 interfaces
}
canvas.DrawText(net.Name, layout.Point{X: leftX, Y: y}, normalStyle)
if len(net.IPAddresses) > 0 {
canvas.DrawText(net.IPAddresses[0], layout.Point{X: leftX + 150, Y: y}, smallStyle)
}
trafficText := fmt.Sprintf("TX: %s RX: %s",
layout.FormatBytes(net.BytesSent),
layout.FormatBytes(net.BytesRecv))
canvas.DrawText(trafficText, layout.Point{X: leftX + 400, Y: y}, smallStyle)
y += 30
}
// Temperature section (bottom right)
if len(info.Temperature) > 0 {
tempY := height - 200
canvas.DrawText("TEMPERATURE", layout.Point{X: rightX, Y: tempY}, headerStyle)
tempY += 35
for sensor, temp := range info.Temperature {
if tempY > height-50 {
break
}
canvas.DrawText(fmt.Sprintf("%s: %.1f°C", sensor, temp),
layout.Point{X: rightX, Y: tempY}, normalStyle)
tempY += 25
}
}
return nil
}

View File

@@ -0,0 +1,139 @@
package renderer
import (
"fmt"
"image/color"
"sort"
"git.eeqj.de/sneak/hdmistat/internal/layout"
"git.eeqj.de/sneak/hdmistat/internal/statcollector"
)
// ProcessScreen displays top processes
type ProcessScreen struct {
SortBy string // "cpu" or "memory"
}
func NewProcessScreenCPU() *ProcessScreen {
return &ProcessScreen{SortBy: "cpu"}
}
func NewProcessScreenMemory() *ProcessScreen {
return &ProcessScreen{SortBy: "memory"}
}
func (s *ProcessScreen) Name() string {
if s.SortBy == "cpu" {
return "Top Processes by CPU"
}
return "Top Processes by Memory"
}
func (s *ProcessScreen) Render(canvas *layout.Canvas, info *statcollector.SystemInfo) error {
width, _ := canvas.Size()
// Colors
textColor := color.RGBA{255, 255, 255, 255}
headerColor := color.RGBA{100, 200, 255, 255}
dimColor := color.RGBA{150, 150, 150, 255}
// Styles
titleStyle := layout.TextStyle{Size: 36, Color: headerColor}
headerStyle := layout.TextStyle{Size: 20, Color: headerColor}
normalStyle := layout.TextStyle{Size: 16, Color: textColor}
smallStyle := layout.TextStyle{Size: 14, Color: dimColor}
y := 50
// Title
canvas.DrawText(s.Name(), layout.Point{X: width / 2, Y: y}, layout.TextStyle{
Size: titleStyle.Size,
Color: titleStyle.Color,
Alignment: layout.AlignCenter,
})
y += 70
// Sort processes
processes := make([]statcollector.ProcessInfo, len(info.Processes))
copy(processes, info.Processes)
if s.SortBy == "cpu" {
sort.Slice(processes, func(i, j int) bool {
return processes[i].CPUPercent > processes[j].CPUPercent
})
} else {
sort.Slice(processes, func(i, j int) bool {
return processes[i].MemoryRSS > processes[j].MemoryRSS
})
}
// Table headers
x := 50
canvas.DrawText("PID", layout.Point{X: x, Y: y}, headerStyle)
canvas.DrawText("USER", layout.Point{X: x + 100, Y: y}, headerStyle)
canvas.DrawText("PROCESS", layout.Point{X: x + 250, Y: y}, headerStyle)
canvas.DrawText("CPU %", layout.Point{X: x + 600, Y: y}, headerStyle)
canvas.DrawText("MEMORY", layout.Point{X: x + 700, Y: y}, headerStyle)
y += 30
canvas.DrawHLine(x, y, width-100, color.RGBA{100, 100, 100, 255})
y += 20
// Display top 20 processes
for i, proc := range processes {
if i >= 20 {
break
}
// Truncate long names
name := proc.Name
if len(name) > 30 {
name = name[:27] + "..."
}
user := proc.Username
if len(user) > 12 {
user = user[:9] + "..."
}
canvas.DrawText(fmt.Sprintf("%d", proc.PID), layout.Point{X: x, Y: y}, normalStyle)
canvas.DrawText(user, layout.Point{X: x + 100, Y: y}, normalStyle)
canvas.DrawText(name, layout.Point{X: x + 250, Y: y}, normalStyle)
canvas.DrawText(fmt.Sprintf("%.1f", proc.CPUPercent), layout.Point{X: x + 600, Y: y}, normalStyle)
canvas.DrawText(layout.FormatBytes(proc.MemoryRSS), layout.Point{X: x + 700, Y: y}, normalStyle)
// Highlight bar for high usage
if s.SortBy == "cpu" && proc.CPUPercent > 50 {
canvas.DrawBox(x-5, y-15, width-90, 20, color.RGBA{100, 50, 50, 100})
} else if s.SortBy == "memory" && float64(proc.MemoryRSS)/float64(info.MemoryTotal) > 0.1 {
canvas.DrawBox(x-5, y-15, width-90, 20, color.RGBA{50, 50, 100, 100})
}
y += 25
}
// Footer with system totals
y = 950
canvas.DrawHLine(50, y, width-100, color.RGBA{100, 100, 100, 255})
y += 30
totalCPU := 0.0
for _, cpu := range info.CPUPercent {
totalCPU += cpu
}
avgCPU := totalCPU / float64(len(info.CPUPercent))
footerText := fmt.Sprintf("System: CPU %.1f%% | Memory: %s / %s (%.1f%%)",
avgCPU,
layout.FormatBytes(info.MemoryUsed),
layout.FormatBytes(info.MemoryTotal),
float64(info.MemoryUsed)/float64(info.MemoryTotal)*100)
canvas.DrawText(footerText, layout.Point{X: width / 2, Y: y}, layout.TextStyle{
Size: smallStyle.Size,
Color: smallStyle.Color,
Alignment: layout.AlignCenter,
})
return nil
}

View File

@@ -0,0 +1,51 @@
package renderer
import (
"image"
"log/slog"
"git.eeqj.de/sneak/hdmistat/internal/layout"
"git.eeqj.de/sneak/hdmistat/internal/statcollector"
"github.com/golang/freetype/truetype"
)
// Screen represents a displayable screen
type Screen interface {
Name() string
Render(canvas *layout.Canvas, info *statcollector.SystemInfo) error
}
// Renderer manages screen rendering
type Renderer struct {
font *truetype.Font
logger *slog.Logger
width int
height int
}
// NewRenderer creates a new renderer
func NewRenderer(font *truetype.Font, logger *slog.Logger) *Renderer {
return &Renderer{
font: font,
logger: logger,
width: 1920, // Default HD resolution
height: 1080,
}
}
// SetResolution sets the rendering resolution
func (r *Renderer) SetResolution(width, height int) {
r.width = width
r.height = height
}
// RenderScreen renders a screen to an image
func (r *Renderer) RenderScreen(screen Screen, info *statcollector.SystemInfo) (*image.RGBA, error) {
canvas := layout.NewCanvas(r.width, r.height, r.font, r.logger)
if err := screen.Render(canvas, info); err != nil {
return nil, err
}
return canvas.Image(), nil
}