hdmistat/internal/app/app.go
sneak 402c0797d5 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.
2025-07-23 12:55:42 +02:00

208 lines
4.0 KiB
Go

package app
import (
"context"
"log/slog"
"sync"
"time"
"git.eeqj.de/sneak/hdmistat/internal/display"
"git.eeqj.de/sneak/hdmistat/internal/renderer"
"git.eeqj.de/sneak/hdmistat/internal/statcollector"
"go.uber.org/fx"
)
// App is the main application
type App struct {
display display.Display
collector statcollector.Collector
renderer *renderer.Renderer
screens []renderer.Screen
logger *slog.Logger
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
currentScreen int
rotationInterval time.Duration
updateInterval time.Duration
}
// Config holds application configuration
type Config struct {
RotationInterval time.Duration
UpdateInterval time.Duration
}
// AppOptions contains all dependencies for the App
type AppOptions struct {
fx.In
Lifecycle fx.Lifecycle
Display display.Display
Collector statcollector.Collector
Renderer *renderer.Renderer
Logger *slog.Logger
Context context.Context
Config *Config `optional:"true"`
}
// NewApp creates a new application instance
func NewApp(opts AppOptions) *App {
config := &Config{
RotationInterval: 10 * time.Second,
UpdateInterval: 1 * time.Second,
}
if opts.Config != nil {
config = opts.Config
}
app := &App{
display: opts.Display,
collector: opts.Collector,
renderer: opts.Renderer,
logger: opts.Logger,
currentScreen: 0,
rotationInterval: config.RotationInterval,
updateInterval: config.UpdateInterval,
}
// Initialize screens
app.screens = []renderer.Screen{
renderer.NewOverviewScreen(),
renderer.NewProcessScreenCPU(),
renderer.NewProcessScreenMemory(),
}
opts.Lifecycle.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
app.ctx, app.cancel = context.WithCancel(ctx)
app.Start()
return nil
},
OnStop: func(ctx context.Context) error {
return app.Stop()
},
})
return app
}
// Start begins the application main loop
func (a *App) Start() {
a.logger.Info("starting hdmistat app",
"screens", len(a.screens),
"rotation_interval", a.rotationInterval,
"update_interval", a.updateInterval)
// Start update loop
a.wg.Add(1)
go a.updateLoop()
// Start rotation loop
a.wg.Add(1)
go a.rotationLoop()
}
// Stop stops the application
func (a *App) Stop() error {
a.logger.Info("stopping hdmistat app")
a.cancel()
a.wg.Wait()
// Clear display
if err := a.display.Clear(); err != nil {
a.logger.Error("clearing display", "error", err)
}
// Close display
if err := a.display.Close(); err != nil {
a.logger.Error("closing display", "error", err)
}
return nil
}
// updateLoop continuously updates the current screen
func (a *App) updateLoop() {
defer a.wg.Done()
ticker := time.NewTicker(a.updateInterval)
defer ticker.Stop()
// Initial render
a.renderCurrentScreen()
for {
select {
case <-a.ctx.Done():
return
case <-ticker.C:
a.renderCurrentScreen()
}
}
}
// rotationLoop rotates through screens
func (a *App) rotationLoop() {
defer a.wg.Done()
ticker := time.NewTicker(a.rotationInterval)
defer ticker.Stop()
for {
select {
case <-a.ctx.Done():
return
case <-ticker.C:
a.nextScreen()
}
}
}
// renderCurrentScreen renders and displays the current screen
func (a *App) renderCurrentScreen() {
if len(a.screens) == 0 {
return
}
// Collect system info
info, err := a.collector.Collect()
if err != nil {
a.logger.Error("collecting system info", "error", err)
return
}
// Get current screen
screen := a.screens[a.currentScreen]
// Render screen
img, err := a.renderer.RenderScreen(screen, info)
if err != nil {
a.logger.Error("rendering screen",
"screen", screen.Name(),
"error", err)
return
}
// Display image
if err := a.display.Show(img); err != nil {
a.logger.Error("displaying image", "error", err)
}
}
// nextScreen advances to the next screen
func (a *App) nextScreen() {
if len(a.screens) == 0 {
return
}
a.currentScreen = (a.currentScreen + 1) % len(a.screens)
a.logger.Info("switching screen",
"index", a.currentScreen,
"name", a.screens[a.currentScreen].Name())
}