385 lines
9.5 KiB
Go
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")
|
|
}
|