//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") }