hdmistat/cmd/fbsimplestat/main.go
2025-07-24 16:09:00 +02:00

385 lines
9.5 KiB
Go

//nolint:mnd
package main
import (
"context"
"fmt"
"image/color"
"log"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"git.eeqj.de/sneak/hdmistat/internal/fbdraw"
"git.eeqj.de/sneak/hdmistat/internal/layout"
"github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/disk"
"github.com/shirou/gopsutil/v3/host"
"github.com/shirou/gopsutil/v3/mem"
)
const (
// DefaultFontSize is the font size used throughout the application
DefaultFontSize = 24
)
// SimpleStatScreen implements the FrameGenerator interface
type SimpleStatScreen struct {
width int
height int
}
// NewSimpleStatScreen creates a new simple stat screen
func NewSimpleStatScreen() *SimpleStatScreen {
return &SimpleStatScreen{}
}
// Init initializes the screen with the display dimensions
func (s *SimpleStatScreen) Init(width, height int) error {
s.width = width
s.height = height
return nil
}
// getSystemUptime returns the system uptime
func getSystemUptime() (time.Duration, error) {
data, err := os.ReadFile("/proc/uptime")
if err != nil {
return 0, err
}
fields := strings.Fields(string(data))
if len(fields) < 1 {
return 0, fmt.Errorf("invalid /proc/uptime format")
}
seconds, err := strconv.ParseFloat(fields[0], 64)
if err != nil {
return 0, err
}
return time.Duration(seconds * float64(time.Second)), nil
}
// GenerateFrame generates a frame with system stats
func (s *SimpleStatScreen) GenerateFrame(grid *fbdraw.CharGrid) error {
// Create a draw context that works directly on the provided grid
draw := layout.NewDraw(grid)
// Clear the screen with a dark background
draw.Clear()
// Get hostname
hostname, _ := os.Hostname()
// Title - moved down one line
draw.Color(layout.Color("cyan")).Bold()
draw.TextCenter(0, 3, "%s: info", hostname)
draw.Plain()
// Uptime
uptime, err := getSystemUptime()
if err != nil {
uptime = 0
}
draw.Color(layout.Color("gray60"))
draw.TextCenter(0, 4, "Uptime: %s", uptime.Round(time.Second).String())
// Starting Y position for stats (with extra blank line after uptime)
statY := 7
// Calculate alignment positions
labelX := 10
valueX := 30 // For right-aligned values before the bar
barX := 40 // Start of progress bars
barWidth := 50 // Extended bar width
// CPU Usage
cpuPercent, _ := cpu.Percent(time.Millisecond*100, false)
cpuUsage := 0.0
if len(cpuPercent) > 0 {
cpuUsage = cpuPercent[0]
}
draw.Color(layout.Color("white"))
draw.Text(labelX, statY, "CPU Usage:")
draw.Text(valueX-6, statY, "%6.1f%%", cpuUsage)
barGrid := draw.Grid(barX, statY, barWidth+1, 1)
barGrid.Bar(0, 0, barWidth, cpuUsage, getCPUColor(cpuUsage))
// Add CPU details below
draw.Color(layout.Color("gray60"))
cpuInfo := getCPUInfo()
draw.Text(labelX+2, statY+1, "%s", cpuInfo)
// Memory Usage
statY += 4 // Add extra blank line (3 + 1 for the CPU detail line)
vmStat, _ := mem.VirtualMemory()
memUsage := vmStat.UsedPercent
draw.Color(layout.Color("white"))
draw.Text(labelX, statY, "Memory:")
draw.Text(valueX-6, statY, "%6.1f%%", memUsage)
barGrid = draw.Grid(barX, statY, barWidth+1, 1)
barGrid.Bar(0, 0, barWidth, memUsage, getMemoryColor(memUsage))
// Add usage details below
draw.Color(layout.Color("gray60"))
draw.Text(
labelX+2,
statY+1,
"%s / %s",
layout.Bytes(vmStat.Used),
layout.Bytes(vmStat.Total),
)
// System Temperature (only if available)
temp := getSystemTemperature()
if temp > 0 {
statY += 4 // Add blank line (3 + 1 for the memory detail line)
tempPercent := (temp / 100.0) * 100.0 // Assume 100°C as max
if tempPercent > 100 {
tempPercent = 100
}
draw.Color(layout.Color("white"))
draw.Text(labelX, statY, "Temperature:")
draw.Text(valueX-8, statY, "%6.1f°C", temp)
barGrid = draw.Grid(barX, statY, barWidth+1, 1)
barGrid.Bar(0, 0, barWidth, tempPercent, getTempColor(temp))
}
// Filesystem Usage
if temp > 0 {
statY += 3 // Add blank line after temperature
} else {
statY += 4 // Add blank line after memory (3 + 1 for the memory detail line)
}
fsPath, fsUsage, fsUsed, fsTotal := getLargestFilesystemUsageWithDetails()
draw.Color(layout.Color("white"))
draw.Text(labelX, statY, "Filesystem:")
draw.Text(valueX-6, statY, "%6.1f%%", fsUsage)
barGrid = draw.Grid(barX, statY, barWidth+1, 1)
barGrid.Bar(0, 0, barWidth, fsUsage, getFSColor(fsUsage))
// Add filesystem details below
draw.Color(layout.Color("gray60"))
draw.Text(
labelX+2,
statY+1,
"%s: %s / %s",
fsPath,
layout.Bytes(fsUsed),
layout.Bytes(fsTotal),
)
// Add a decorative border
borderGrid := draw.Grid(2, 2, grid.Width-4, grid.Height-4)
borderGrid.Border(layout.Color("gray30"))
return nil
}
// FramesPerSecond returns the desired frame rate (1 FPS for stat updates)
func (s *SimpleStatScreen) FramesPerSecond() float64 {
return 1.0
}
// getCPUColor returns a color based on CPU usage
func getCPUColor(percent float64) color.Color {
if percent > 80 {
return layout.Color("red")
} else if percent > 50 {
return layout.Color("yellow")
}
return layout.Color("green")
}
// getMemoryColor returns a color based on memory usage
func getMemoryColor(percent float64) color.Color {
if percent > 90 {
return layout.Color("red")
} else if percent > 70 {
return layout.Color("yellow")
}
return layout.Color("green")
}
// getTempColor returns a color based on temperature
func getTempColor(temp float64) color.Color {
if temp > 80 {
return layout.Color("red")
} else if temp > 60 {
return layout.Color("yellow")
}
return layout.Color("green")
}
// getFSColor returns a color based on filesystem usage
func getFSColor(percent float64) color.Color {
if percent > 90 {
return layout.Color("red")
} else if percent > 80 {
return layout.Color("yellow")
}
return layout.Color("green")
}
// getCPUInfo returns CPU details like cores, threads, and clock speed
func getCPUInfo() string {
// Get CPU info
cpuInfos, err := cpu.Info()
if err != nil || len(cpuInfos) == 0 {
return "Unknown CPU"
}
// Get logical (threads) and physical core counts
logical, _ := cpu.Counts(true)
physical, _ := cpu.Counts(false)
// Get frequency info
freq := cpuInfos[0].Mhz
freqStr := ""
if freq > 0 {
if freq >= 1000 {
freqStr = fmt.Sprintf("%.2f GHz", freq/1000.0)
} else {
freqStr = fmt.Sprintf("%.0f MHz", freq)
}
return fmt.Sprintf(
"%d cores, %d threads @ %s",
physical,
logical,
freqStr,
)
}
return fmt.Sprintf("%d cores, %d threads", physical, logical)
}
// getSystemTemperature returns the system temperature in Celsius
func getSystemTemperature() float64 {
// Try to get temperature from host sensors
temps, err := host.SensorsTemperatures()
if err != nil || len(temps) == 0 {
return 0.0
}
// Find the highest temperature
maxTemp := 0.0
for _, temp := range temps {
if temp.Temperature > maxTemp {
maxTemp = temp.Temperature
}
}
return maxTemp
}
// getLargestFilesystemUsageWithDetails returns the path, usage percentage, used bytes, and total bytes
func getLargestFilesystemUsageWithDetails() (string, float64, uint64, uint64) {
partitions, err := disk.Partitions(false)
if err != nil {
return "/", 0.0, 0, 0
}
var largestPath string
var largestSize uint64
var largestUsage float64
var largestUsed uint64
for _, partition := range partitions {
// Skip special filesystems
if strings.HasPrefix(partition.Mountpoint, "/proc") ||
strings.HasPrefix(partition.Mountpoint, "/sys") ||
strings.HasPrefix(partition.Mountpoint, "/dev") ||
strings.HasPrefix(partition.Mountpoint, "/run") ||
partition.Fstype == "tmpfs" ||
partition.Fstype == "devtmpfs" {
continue
}
usage, err := disk.Usage(partition.Mountpoint)
if err != nil {
continue
}
// Find the filesystem with the largest total size
if usage.Total > largestSize {
largestSize = usage.Total
largestUsage = usage.UsedPercent
largestUsed = usage.Used
largestPath = partition.Mountpoint
}
}
if largestPath == "" {
return "/", 0.0, 0, 0
}
return largestPath, largestUsage, largestUsed, largestSize
}
func main() {
// Set up signal handling
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
log.Println("Received shutdown signal")
cancel()
}()
// Create framebuffer display
display, err := fbdraw.NewFBDisplayAuto()
if err != nil {
log.Fatalf("Failed to open framebuffer: %v", err)
}
defer func() {
if err := display.Close(); err != nil {
log.Printf("Failed to close display: %v", err)
}
}()
// Create carousel with no rotation (single screen)
carousel := fbdraw.NewCarousel(display, 0) // 0 means no rotation
// Set font size
if err := carousel.SetFontSize(DefaultFontSize); err != nil {
log.Fatalf("Failed to set font size: %v", err)
}
// Add our simple stat screen with header
statScreen := NewSimpleStatScreen()
wrappedScreen := fbdraw.NewHeaderWrapper(statScreen)
if err := carousel.AddScreen("Simple Stats", wrappedScreen); err != nil {
log.Fatalf("Failed to add screen: %v", err)
}
// Run the carousel
log.Println("Starting fbsimplestat...")
log.Println("Press Ctrl+C to exit")
// Run carousel in a goroutine
done := make(chan error, 1)
go func() {
done <- carousel.Run()
}()
// Wait for either context cancellation or carousel to finish
select {
case <-ctx.Done():
log.Println("Stopping carousel...")
carousel.Stop()
<-done // Wait for carousel to finish
case err := <-done:
if err != nil {
log.Printf("Carousel error: %v", err)
}
}
log.Println("fbsimplestat exited cleanly")
}