checkpointing, heavy dev

This commit is contained in:
Jeffrey Paul 2025-07-24 14:32:50 +02:00
parent a3bc63d2d9
commit c2040a5c08
89 changed files with 741883 additions and 477 deletions

7
.gitignore vendored
View File

@ -18,6 +18,11 @@ go.work.sum
# Dependency directories
vendor/
# vms
*.qcow2
*cloudimg-*.img
cloud-init.iso
# IDE files
.idea/
.vscode/
@ -31,4 +36,4 @@ Thumbs.db
# Build artifacts
dist/
build/
build/

View File

@ -43,20 +43,6 @@ linters-settings:
- operation
- return
- assign
ignored-numbers:
- '0'
- '1'
- '2'
- '8'
- '16'
- '40' # GPG fingerprint length
- '64'
- '128'
- '256'
- '512'
- '1024'
- '2048'
- '4096'
nestif:
min-complexity: 4
@ -72,14 +58,6 @@ linters-settings:
- []
- "upperCaseConst=true"
tagliatelle:
case:
rules:
json: snake
yaml: snake
xml: snake
bson: snake
testifylint:
enable-all: true
@ -102,27 +80,3 @@ issues:
- text: "don't use ALL_CAPS in Go names"
linters:
- revive
# Exclude all linters for internal/macse directory
- path: "internal/macse/.*"
linters:
- errcheck
- lll
- mnd
- nestif
- nlreturn
- revive
- unconvert
- govet
- staticcheck
- unused
- ineffassign
- misspell
- gosec
- unparam
- testifylint
- usetesting
- tagliatelle
- nilnil
- intrange
- gochecknoglobals

View File

@ -1,4 +1,9 @@
## Development Workflow
- Never run tests in isolation, or via 'go test' - always use 'make test' which does format and lint checks and then runs the test. 'make test' MUST always be run, and MUST always pass, before committing.
- Do not modify the linter config without explicit permission.
- Do not modify the linter config without explicit permission.
- VERY IMPORTANT: remember never to commit if there are linter or test errors, or if the code is not formatted. you MUST fix all linter errors first, and you must do it WITHOUT changing the linter configuration.
## Ethical Guidelines
- claude cannot author code. claude cannot co-author code. claude is an inanimate tool. NEVER mention claude or anthropic for any reason.

View File

@ -13,7 +13,7 @@ fmt:
fmt-check:
@echo "Checking formatting..."
@test -z "$$(gofmt -l .)" || (echo "Files need formatting. Run 'make fmt'" && gofmt -l . && exit 1)
@test -z "$$(gofmt -l . | grep -v '^vendor/')" || (echo "Files need formatting. Run 'make fmt'" && gofmt -l . | grep -v '^vendor/' && exit 1)
lint:
golangci-lint run
@ -39,4 +39,25 @@ run-daemon: build
deps:
go mod download
go mod tidy
go mod tidy
# VM Testing
.PHONY: vmtest
vmtest:
cd test/qemu-ubuntu-test && ./run-qemu.sh
# Debug build
.PHONY: debug
debug:
go build -gcflags="all=-N -l" -o hdmistat-debug ./cmd/hdmistat
@echo "Debug build complete: hdmistat-debug"
@echo ""
@echo "Debug features:"
@echo " - SIGUSR1 dumps all goroutine stack traces"
@echo " - Built with debug symbols (-N -l)"
@echo ""
@echo "To debug a hang:"
@echo " kill -USR1 <pid> # Dump goroutines"
@echo " kill -QUIT <pid> # Also dumps goroutines (Go default)"

View File

@ -1,3 +1,4 @@
// Package main is the entry point for the hdmistat command-line tool
package main
import (

4
go.mod
View File

@ -4,6 +4,7 @@ go 1.24.4
require (
git.eeqj.de/sneak/smartconfig v1.0.0
github.com/dustin/go-humanize v1.0.1
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
github.com/shirou/gopsutil/v3 v3.24.1
github.com/spf13/cobra v1.8.0
@ -17,7 +18,6 @@ require (
cloud.google.com/go/compute/metadata v0.7.0 // indirect
cloud.google.com/go/iam v1.5.2 // indirect
cloud.google.com/go/secretmanager v1.15.0 // indirect
git.eeqj.de/sneak/smartconfig v1.0.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect
@ -43,7 +43,6 @@ require (
github.com/coreos/go-semver v0.3.1 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
@ -115,7 +114,6 @@ require (
go.opentelemetry.io/otel v1.36.0 // indirect
go.opentelemetry.io/otel/metric v1.36.0 // indirect
go.opentelemetry.io/otel/trace v1.36.0 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/dig v1.17.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect

69
go.sum
View File

@ -1,3 +1,5 @@
cloud.google.com/go v0.120.0 h1:wc6bgG9DHyKqF5/vQvX1CiZrtHnxJjBlKUyF9nP6meA=
cloud.google.com/go v0.120.0/go.mod h1:/beW32s8/pGRuj4IILWQNd4uuebeT4dkOhKmkfit64Q=
cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4=
cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
@ -14,12 +16,16 @@ github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=
github.com/Azure/azure-sdk-for-go/sdk/keyvault/azsecrets v0.12.0 h1:xnO4sFyG8UH2fElBkcqLTOZsAajvKfnSlgBBW8dXYjw=
github.com/Azure/azure-sdk-for-go/sdk/keyvault/azsecrets v0.12.0/go.mod h1:XD3DIOOVgBCO03OleB1fHjgktVRFxlT++KwKgIOewdM=
github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 h1:FbH3BbSb4bvGluTesZZ+ttN/MDsnMmQP36OSnDuSXqw=
github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1/go.mod h1:9V2j0jn9jDEkCkv8w/bKTNppX/d0FVA1ud77xCIP4KA=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
@ -70,6 +76,8 @@ github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kB
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4=
@ -79,10 +87,11 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
@ -118,6 +127,10 @@ github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw=
github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
@ -132,17 +145,20 @@ github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw=
github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@ -155,6 +171,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5uk
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
github.com/hashicorp/consul/api v1.32.1 h1:0+osr/3t/aZNAdJX558crU3PEjVrG4x6715aZHRgceE=
github.com/hashicorp/consul/api v1.32.1/go.mod h1:mXUWLnxftwTmDv4W3lzxYCPD199iNLLUyLfLGFJbtl4=
github.com/hashicorp/consul/sdk v0.16.1 h1:V8TxTnImoPD5cj0U9Spl0TUxcytjcbbJeADFF07KdHg=
github.com/hashicorp/consul/sdk v0.16.1/go.mod h1:fSXvwxB2hmh1FMZCNl6PwX0Q/1wdWtHJcZ7Ea5tns0s=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@ -167,6 +185,8 @@ github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjh
github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
github.com/hashicorp/go-msgpack v0.5.5 h1:i9R9JSrqIz0QVLz3sz+i3YJdT7TTSLcfLLzJi9aZTuI=
github.com/hashicorp/go-msgpack v0.5.5/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
@ -187,6 +207,10 @@ github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjG
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.2.1 h1:zEfKbn2+PDgroKdiOzqiE8rsmLqU2uwi5PB5pBJ3TkI=
github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
@ -194,6 +218,7 @@ github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc=
github.com/hashicorp/memberlist v0.5.0 h1:EtYPN8DpAURiapus508I4n9CzHs2W+8NZGbmmR/prTM=
github.com/hashicorp/memberlist v0.5.0/go.mod h1:yvyXLpo0QaGE59Y7hDTsTzDD25JYBZ4mHgHUZ8lrOI0=
github.com/hashicorp/serf v0.10.1 h1:Z1H2J60yRKvfDYAOZLd2MU0ND4AH/WDz7xYHDWQsIPY=
github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4=
@ -208,14 +233,19 @@ github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/u
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
@ -240,6 +270,7 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
github.com/miekg/dns v1.1.41 h1:WMszZWJG0XmzbK9FEmzH2TVcqYzFesusSIB41b8KHxY=
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI=
@ -260,17 +291,22 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY=
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
@ -286,11 +322,16 @@ github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8b
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI=
github.com/redis/go-redis/v9 v9.8.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/shirou/gopsutil/v3 v3.24.1 h1:R3t6ondCEvmARp3wxODhXMTLC/klMa87h2PHUw5m7QI=
github.com/shirou/gopsutil/v3 v3.24.1/go.mod h1:UU7a2MSBQa+kW1uuDq8DeEBS8kmrnQwsv2b5O513rwU=
@ -308,6 +349,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@ -316,8 +359,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
@ -351,6 +395,10 @@ go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
@ -359,15 +407,10 @@ go.uber.org/dig v1.17.0 h1:5Chju+tUvcC+N7N6EV08BJz41UZuO3BmHcN4A287ZLI=
go.uber.org/dig v1.17.0/go.mod h1:rTxpf7l5I0eBTlE6/9RL+lDybC7WFwY2QH55ZSjy1mU=
go.uber.org/fx v1.20.1 h1:zVwVQGS8zYvhh9Xxcu4w1M6ESyeMzebzj2NbSayZ4Mk=
go.uber.org/fx v1.20.1/go.mod h1:iSYNbHf2y55acNCwCXKx7LbWb5WG1Bnue5RDXz1OREg=
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.23.0 h1:OjGQ5KQDEUawVHxNwQgPpiypGHOxo2mNZsOqTak4fFY=
go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
@ -432,7 +475,6 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
@ -452,6 +494,8 @@ golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -471,6 +515,7 @@ google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=

View File

@ -1,3 +1,4 @@
// Package app contains the main application logic for hdmistat
package app
import (
@ -19,7 +20,7 @@ type App struct {
collector statcollector.Collector
renderer *renderer.Renderer
screens []renderer.Screen
logger *slog.Logger
log *slog.Logger
ctx context.Context
cancel context.CancelFunc
@ -30,8 +31,8 @@ type App struct {
updateInterval time.Duration
}
// AppOptions contains all dependencies for the App
type AppOptions struct {
// Options contains all dependencies for the App
type Options struct {
fx.In
Lifecycle fx.Lifecycle
@ -44,12 +45,12 @@ type AppOptions struct {
}
// NewApp creates a new application instance
func NewApp(opts AppOptions) *App {
func NewApp(opts Options) *App {
app := &App{
display: opts.Display,
collector: opts.Collector,
renderer: opts.Renderer,
logger: opts.Logger,
log: opts.Logger,
currentScreen: 0,
rotationInterval: opts.Config.GetRotationDuration(),
updateInterval: opts.Config.GetUpdateDuration(),
@ -57,18 +58,21 @@ func NewApp(opts AppOptions) *App {
// Initialize screens
app.screens = []renderer.Screen{
renderer.NewOverviewScreen(),
renderer.NewStatusScreen(), // New status screen
renderer.NewOverviewScreen(), // Old overview screen
renderer.NewProcessScreenCPU(),
renderer.NewProcessScreenMemory(),
}
// Use the injected context, not the lifecycle context
app.ctx, app.cancel = context.WithCancel(opts.Context)
opts.Lifecycle.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
app.ctx, app.cancel = context.WithCancel(ctx)
OnStart: func(_ context.Context) error {
app.Start()
return nil
},
OnStop: func(ctx context.Context) error {
OnStop: func(_ context.Context) error {
return app.Stop()
},
})
@ -78,7 +82,7 @@ func NewApp(opts AppOptions) *App {
// Start begins the application main loop
func (a *App) Start() {
a.logger.Info("starting hdmistat app",
a.log.Info("starting hdmistat app",
"screens", len(a.screens),
"rotation_interval", a.rotationInterval,
"update_interval", a.updateInterval)
@ -94,19 +98,19 @@ func (a *App) Start() {
// Stop stops the application
func (a *App) Stop() error {
a.logger.Info("stopping hdmistat app")
a.log.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)
a.log.Error("clearing display", "error", err)
}
// Close display
if err := a.display.Close(); err != nil {
a.logger.Error("closing display", "error", err)
a.log.Error("closing display", "error", err)
}
return nil
@ -115,13 +119,28 @@ func (a *App) Stop() error {
// updateLoop continuously updates the current screen
func (a *App) updateLoop() {
defer a.wg.Done()
defer func() {
a.log.Info("updateLoop exiting")
if r := recover(); r != nil {
a.log.Error("updateLoop panic", "error", r)
}
}()
ticker := time.NewTicker(a.updateInterval)
defer ticker.Stop()
a.log.Debug("updateLoop started")
// DISABLED FOR DEBUGGING - Only render once on screen switch
// ticker := time.NewTicker(a.updateInterval)
// defer ticker.Stop()
// Initial render
a.renderCurrentScreen()
// Just wait for context cancellation
a.log.Debug("updateLoop waiting for context cancellation")
<-a.ctx.Done()
a.log.Debug("updateLoop context cancelled")
/* COMMENTED OUT FOR DEBUGGING
for {
select {
case <-a.ctx.Done():
@ -130,11 +149,20 @@ func (a *App) updateLoop() {
a.renderCurrentScreen()
}
}
*/
}
// rotationLoop rotates through screens
func (a *App) rotationLoop() {
defer a.wg.Done()
defer func() {
a.log.Info("rotationLoop exiting")
if r := recover(); r != nil {
a.log.Error("rotationLoop panic", "error", r)
}
}()
a.log.Debug("rotationLoop started", "interval", a.rotationInterval)
ticker := time.NewTicker(a.rotationInterval)
defer ticker.Stop()
@ -142,8 +170,10 @@ func (a *App) rotationLoop() {
for {
select {
case <-a.ctx.Done():
a.log.Debug("rotationLoop context cancelled")
return
case <-ticker.C:
a.log.Debug("rotationLoop ticker fired")
a.nextScreen()
}
}
@ -155,20 +185,23 @@ func (a *App) renderCurrentScreen() {
return
}
// Get current screen
screen := a.screens[a.currentScreen]
a.log.Debug("rendering screen",
"index", a.currentScreen,
"name", screen.Name())
// Collect system info
info, err := a.collector.Collect()
if err != nil {
a.logger.Error("collecting system info", "error", err)
a.log.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",
a.log.Error("rendering screen",
"screen", screen.Name(),
"error", err)
return
@ -176,7 +209,7 @@ func (a *App) renderCurrentScreen() {
// Display image
if err := a.display.Show(img); err != nil {
a.logger.Error("displaying image", "error", err)
a.log.Error("displaying image", "error", err)
}
}
@ -187,7 +220,10 @@ func (a *App) nextScreen() {
}
a.currentScreen = (a.currentScreen + 1) % len(a.screens)
a.logger.Info("switching screen",
a.log.Info("switching screen",
"index", a.currentScreen,
"name", a.screens[a.currentScreen].Name())
// Render the new screen immediately
a.renderCurrentScreen()
}

View File

@ -1,13 +1,20 @@
// Package config provides configuration management for hdmistat
package config
import (
"context"
"log/slog"
"os"
"time"
"git.eeqj.de/sneak/smartconfig"
)
const (
defaultWidth = 1920
defaultHeight = 1080
)
// Config holds the application configuration
type Config struct {
FramebufferDevice string
@ -24,7 +31,7 @@ type Config struct {
}
// Load loads configuration from all available sources
func Load(ctx context.Context) (*Config, error) {
func Load(_ context.Context) (*Config, error) {
// Start with defaults
cfg := &Config{
FramebufferDevice: "/dev/fb0",
@ -32,8 +39,8 @@ func Load(ctx context.Context) (*Config, error) {
UpdateInterval: "1s",
Screens: []string{"overview", "top_cpu", "top_memory"},
LogLevel: "info",
Width: 1920,
Height: 1080,
Width: defaultWidth,
Height: defaultHeight,
}
// Try to load from the default location for hdmistat
@ -77,6 +84,11 @@ func Load(ctx context.Context) (*Config, error) {
}
}
// Override with environment variables if set
if envLogLevel := os.Getenv("HDMISTAT_LOG_LEVEL"); envLogLevel != "" {
cfg.LogLevel = envLogLevel
}
// Parse durations
cfg.rotationDuration, err = time.ParseDuration(cfg.RotationInterval)
if err != nil {

View File

@ -1,3 +1,4 @@
// Package display provides framebuffer display functionality
package display
import (
@ -18,10 +19,12 @@ type Display interface {
// FramebufferDisplay implements Display for Linux framebuffer
type FramebufferDisplay struct {
file *os.File
info *fbVarScreenInfo
memory []byte
device string
logger *slog.Logger
// Cached screen info
width uint32
height uint32
bpp uint32
}
type fbVarScreenInfo struct {
@ -48,29 +51,29 @@ type fbBitfield struct {
const (
fbiogetVscreeninfo = 0x4600
bitsPerByte = 8
bpp32 = 32
bpp24 = 24
colorShift = 8
)
// NewFramebufferDisplay creates a new framebuffer display
func NewFramebufferDisplay(device string, logger *slog.Logger) (*FramebufferDisplay, error) {
file, err := os.OpenFile(device, os.O_RDWR, 0)
// Open framebuffer device temporarily to get screen info
file, err := os.OpenFile(device, os.O_RDWR, 0) // #nosec G304 - device path is controlled
if err != nil {
return nil, fmt.Errorf("opening framebuffer: %w", err)
}
defer func() { _ = file.Close() }()
var info fbVarScreenInfo
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, file.Fd(), fbiogetVscreeninfo, uintptr(unsafe.Pointer(&info)))
// #nosec G103 - required for framebuffer ioctl
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, file.Fd(), fbiogetVscreeninfo,
uintptr(unsafe.Pointer(&info)))
if errno != 0 {
_ = file.Close()
return nil, fmt.Errorf("getting screen info: %v", errno)
}
size := int(info.XRes * info.YRes * info.BitsPerPixel / 8)
memory, err := syscall.Mmap(int(file.Fd()), 0, size, syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_SHARED)
if err != nil {
_ = file.Close()
return nil, fmt.Errorf("mapping framebuffer: %w", err)
}
logger.Info("framebuffer initialized",
"device", device,
"width", info.XRes,
@ -78,45 +81,87 @@ func NewFramebufferDisplay(device string, logger *slog.Logger) (*FramebufferDisp
"bpp", info.BitsPerPixel)
return &FramebufferDisplay{
file: file,
info: &info,
memory: memory,
device: device,
logger: logger,
width: info.XRes,
height: info.YRes,
bpp: info.BitsPerPixel,
}, nil
}
// Show displays an image on the framebuffer
func (d *FramebufferDisplay) Show(img *image.RGBA) error {
if d == nil || d.device == "" {
return fmt.Errorf("invalid display")
}
// Open framebuffer device
file, err := os.OpenFile(d.device, os.O_RDWR, 0) // #nosec G304 - device path is controlled
if err != nil {
return fmt.Errorf("opening framebuffer: %w", err)
}
defer func() { _ = file.Close() }()
// Get screen information (in case it changed)
var info fbVarScreenInfo
// #nosec G103 - required for framebuffer ioctl
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, file.Fd(), fbiogetVscreeninfo,
uintptr(unsafe.Pointer(&info)))
if errno != 0 {
return fmt.Errorf("getting screen info: %v", errno)
}
bounds := img.Bounds()
width := bounds.Dx()
height := bounds.Dy()
if width > int(d.info.XRes) {
width = int(d.info.XRes)
if width > int(info.XRes) {
width = int(info.XRes)
}
if height > int(d.info.YRes) {
height = int(d.info.YRes)
if height > int(info.YRes) {
height = int(info.YRes)
}
// Create buffer for one line at a time
lineSize := int(info.XRes * info.BitsPerPixel / bitsPerByte)
line := make([]byte, lineSize)
// Write image data line by line
for y := 0; y < height; y++ {
// Clear line buffer
for i := range line {
line[i] = 0
}
// Fill line buffer with pixel data
for x := 0; x < width; x++ {
r, g, b, a := img.At(x, y).RGBA()
r, g, b = r>>8, g>>8, b>>8
r, g, b = r>>colorShift, g>>colorShift, b>>colorShift
offset := (y*int(d.info.XRes) + x) * int(d.info.BitsPerPixel/8)
if offset+3 < len(d.memory) {
if d.info.BitsPerPixel == 32 {
d.memory[offset] = byte(b)
d.memory[offset+1] = byte(g)
d.memory[offset+2] = byte(r)
d.memory[offset+3] = byte(a >> 8)
} else if d.info.BitsPerPixel == 24 {
d.memory[offset] = byte(b)
d.memory[offset+1] = byte(g)
d.memory[offset+2] = byte(r)
offset := x * int(info.BitsPerPixel/bitsPerByte)
if offset+3 < len(line) {
switch info.BitsPerPixel {
case bpp32:
line[offset] = byte(b)
line[offset+1] = byte(g)
line[offset+2] = byte(r)
line[offset+3] = byte(a >> colorShift)
case bpp24:
line[offset] = byte(b)
line[offset+1] = byte(g)
line[offset+2] = byte(r)
}
}
}
// Seek to correct position and write line
seekPos := int64(y) * int64(lineSize)
if _, err := file.Seek(seekPos, 0); err != nil {
return fmt.Errorf("seeking in framebuffer: %w", err)
}
if _, err := file.Write(line); err != nil {
return fmt.Errorf("writing to framebuffer: %w", err)
}
}
return nil
@ -124,16 +169,56 @@ func (d *FramebufferDisplay) Show(img *image.RGBA) error {
// Clear clears the framebuffer
func (d *FramebufferDisplay) Clear() error {
for i := range d.memory {
d.memory[i] = 0
if d == nil || d.device == "" {
return fmt.Errorf("invalid display")
}
// Open framebuffer device
file, err := os.OpenFile(d.device, os.O_RDWR, 0) // #nosec G304 - device path is controlled
if err != nil {
return fmt.Errorf("opening framebuffer: %w", err)
}
defer func() { _ = file.Close() }()
// Get screen information
var info fbVarScreenInfo
// #nosec G103 - required for framebuffer ioctl
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, file.Fd(), fbiogetVscreeninfo,
uintptr(unsafe.Pointer(&info)))
if errno != 0 {
return fmt.Errorf("getting screen info: %v", errno)
}
// Create empty buffer
lineSize := int(info.XRes * info.BitsPerPixel / bitsPerByte)
emptyLine := make([]byte, lineSize)
// Write empty lines
for y := 0; y < int(info.YRes); y++ {
seekPos := int64(y) * int64(lineSize)
if _, err := file.Seek(seekPos, 0); err != nil {
return fmt.Errorf("seeking in framebuffer: %w", err)
}
if _, err := file.Write(emptyLine); err != nil {
return fmt.Errorf("writing to framebuffer: %w", err)
}
}
return nil
}
// Close closes the framebuffer
func (d *FramebufferDisplay) Close() error {
if err := syscall.Munmap(d.memory); err != nil {
d.logger.Error("unmapping framebuffer", "error", err)
}
return d.file.Close()
// Nothing to close since we open/close on each operation
return nil
}
// GetWidth returns the framebuffer width
func (d *FramebufferDisplay) GetWidth() uint32 {
return d.width
}
// GetHeight returns the framebuffer height
func (d *FramebufferDisplay) GetHeight() uint32 {
return d.height
}

184
internal/fbdraw/EXAMPLE.md Normal file
View File

@ -0,0 +1,184 @@
# fbdraw Carousel Example
This example demonstrates how to use the fbdraw carousel API to create a rotating display with multiple screens, each updating at its own frame rate.
```go
package main
import (
"fmt"
"math"
"math/rand"
"time"
"git.eeqj.de/sneak/hdmistat/internal/fbdraw"
"git.eeqj.de/sneak/hdmistat/internal/font"
)
// SystemStatusGenerator generates frames showing system status
type SystemStatusGenerator struct {
frameCount int
}
func (g *SystemStatusGenerator) GenerateFrame(grid *fbdraw.CharGrid) error {
g.frameCount++
w := fbdraw.NewGridWriter(grid)
// Clear and draw header
w.Clear()
w.SetColor(fbdraw.Cyan).SetWeight(font.WeightBold)
w.MoveAbs(0, 0).WriteLine("=== SYSTEM STATUS ===")
// Animate with frame count
w.SetColor(fbdraw.White).SetWeight(font.WeightRegular)
w.MoveAbs(0, 2).WriteLine("Frame: %d", g.frameCount)
w.MoveAbs(0, 3).WriteLine("Time: %s", time.Now().Format("15:04:05.000"))
// Animated CPU meter
cpuUsage := 50 + 30*math.Sin(float64(g.frameCount)*0.1)
w.MoveAbs(0, 5).Write("CPU: [")
w.DrawMeter(cpuUsage, 20)
w.Write("] %.1f%%", cpuUsage)
return nil
}
func (g *SystemStatusGenerator) FramesPerSecond() float64 {
return 15.0 // 15 FPS
}
// NetworkMonitorGenerator shows network activity
type NetworkMonitorGenerator struct {
packets []float64
}
func (g *NetworkMonitorGenerator) GenerateFrame(grid *fbdraw.CharGrid) error {
w := fbdraw.NewGridWriter(grid)
// Update data
if len(g.packets) > 50 {
g.packets = g.packets[1:]
}
g.packets = append(g.packets, rand.Float64()*100)
// Draw
w.Clear()
w.SetColor(fbdraw.Green).SetWeight(font.WeightBold)
w.MoveAbs(0, 0).WriteLine("=== NETWORK MONITOR ===")
// Draw graph
w.SetColor(fbdraw.White).SetWeight(font.WeightRegular)
for i, val := range g.packets {
height := int(val / 10) // Scale to 0-10
for y := 10; y > 10-height; y-- {
w.MoveAbs(i+5, y).Write("█")
}
}
w.MoveAbs(0, 12).WriteLine("Packets/sec: %.0f", g.packets[len(g.packets)-1])
return nil
}
func (g *NetworkMonitorGenerator) FramesPerSecond() float64 {
return 10.0 // 10 FPS
}
// ProcessListGenerator shows top processes
type ProcessListGenerator struct {
updateCount int
}
func (g *ProcessListGenerator) GenerateFrame(grid *fbdraw.CharGrid) error {
g.updateCount++
w := fbdraw.NewGridWriter(grid)
w.Clear()
w.SetColor(fbdraw.Yellow).SetWeight(font.WeightBold)
w.MoveAbs(0, 0).WriteLine("=== TOP PROCESSES ===")
// Table header
w.MoveAbs(0, 2).SetColor(fbdraw.White).SetWeight(font.WeightBold)
w.WriteLine("PID CPU% PROCESS")
w.WriteLine("----- ----- ----------------")
// Fake process data
w.SetWeight(font.WeightRegular)
processes := []struct {
pid int
cpu float64
name string
}{
{1234, 42.1 + float64(g.updateCount%10), "firefox"},
{5678, 18.7, "vscode"},
{9012, 8.3, "dockerd"},
}
for i, p := range processes {
if p.cpu > 30 {
w.SetColor(fbdraw.Red)
} else if p.cpu > 15 {
w.SetColor(fbdraw.Yellow)
} else {
w.SetColor(fbdraw.White)
}
w.MoveAbs(0, 4+i)
w.WriteLine("%-5d %5.1f %s", p.pid, p.cpu, p.name)
}
w.MoveAbs(0, 10).SetColor(fbdraw.Gray60)
w.WriteLine("Update #%d", g.updateCount)
return nil
}
func (g *ProcessListGenerator) FramesPerSecond() float64 {
return 1.0 // 1 FPS - processes don't change that fast
}
func main() {
// Initialize display (auto-detect framebuffer)
display, err := fbdraw.NewFBDisplayAuto()
if err != nil {
// Fall back to terminal display
display = fbdraw.NewTerminalDisplay(80, 25)
}
defer display.Close()
// Create carousel with 10 second rotation
carousel := fbdraw.NewCarousel(display, 10*time.Second)
// Add screens with their generators
carousel.AddScreen("System Status", &SystemStatusGenerator{})
carousel.AddScreen("Network Monitor", &NetworkMonitorGenerator{})
carousel.AddScreen("Process List", &ProcessListGenerator{})
// Start the carousel (blocks until interrupted)
if err := carousel.Run(); err != nil {
fmt.Printf("Carousel error: %v\n", err)
}
}
```
## Key Features Demonstrated
1. **Multiple Display Types**: The example tries to auto-detect a framebuffer, falling back to terminal display if not available.
2. **Different Frame Rates**: Each screen updates at its own rate:
- System Status: 15 FPS (smooth animations)
- Network Monitor: 10 FPS (moderate updates)
- Process List: 1 FPS (slow changing data)
3. **GridWriter API**: Shows various drawing operations:
- `MoveAbs()` for absolute positioning
- `Move()` for relative movement
- `DrawMeter()` for progress bars with automatic coloring
- `SetColor()`, `SetWeight()` for styling
4. **Carousel Management**: The carousel automatically:
- Rotates screens every 10 seconds
- Manages frame timing for each screen
- Only renders the active screen
5. **Animation**: The system status screen demonstrates smooth animation using frame counting and sine waves.

335
internal/fbdraw/README.md Normal file
View File

@ -0,0 +1,335 @@
# fbdraw - Simple Framebuffer Drawing for Go
A high-level Go package for creating text-based displays on Linux framebuffers. Designed for system monitors, status displays, and embedded systems.
## API Design
### Basic Example
```go
package main
import (
"github.com/example/fbdraw"
)
func main() {
// Auto-detect and initialize
fb := fbdraw.Init()
defer fb.Close()
// Main render loop
fb.Loop(func(d *fbdraw.Draw) {
// Clear screen
d.Clear()
// Draw header - state is maintained between calls
d.Font(fbdraw.PlexSans).Size(36).Bold()
d.Color(fbdraw.White)
d.TextCenter(d.Width/2, 50, "System Monitor")
// Switch to normal text
d.Font(fbdraw.PlexMono).Size(14).Plain()
// Create a text grid for the main content area
grid := d.Grid(20, 100, 80, 30) // x, y, cols, rows
// Write to the grid using row,col coordinates
grid.Color(fbdraw.Green)
grid.Write(0, 0, "Hostname:")
grid.Write(0, 15, getHostname())
grid.Write(2, 0, "Uptime:")
grid.Write(2, 15, getUptime())
grid.Write(4, 0, "Load Average:")
if load := getLoad(); load > 2.0 {
grid.Color(fbdraw.Red)
}
grid.Write(4, 15, "%.2f %.2f %.2f", getLoad())
// CPU section with meter characters
grid.Color(fbdraw.Blue).Bold()
grid.Write(8, 0, "CPU Usage:")
grid.Plain()
cpus := getCPUPercents()
for i, pct := range cpus {
grid.Write(10+i, 0, "CPU%d [%s] %5.1f%%", i,
fbdraw.Meter(pct, 20), pct)
}
// Memory section
grid.Color(fbdraw.Yellow).Bold()
grid.Write(20, 0, "Memory:")
grid.Plain().Color(fbdraw.White)
mem := getMemoryInfo()
grid.Write(22, 0, "Used: %s / %s",
fbdraw.Bytes(mem.Used), fbdraw.Bytes(mem.Total))
grid.Write(23, 0, "Free: %s", fbdraw.Bytes(mem.Free))
grid.Write(24, 0, "Cache: %s", fbdraw.Bytes(mem.Cache))
// Present the frame
d.Present()
})
}
```
### Dashboard Example with Multiple Grids
```go
func renderDashboard(d *fbdraw.Draw) {
d.Clear(fbdraw.Gray10) // Dark background
// Header
d.Font(fbdraw.PlexSans).Size(48).Bold().Color(fbdraw.Cyan)
d.TextCenter(d.Width/2, 60, "SERVER STATUS")
// Reset to default for grids
d.Font(fbdraw.PlexMono).Size(16).Plain()
// Left panel - System Info
leftGrid := d.Grid(20, 120, 40, 25)
leftGrid.Background(fbdraw.Gray20)
leftGrid.Border(fbdraw.Gray40)
leftGrid.Color(fbdraw.Green).Bold()
leftGrid.WriteCenter(0, "SYSTEM")
leftGrid.Plain().Color(fbdraw.White)
info := getSystemInfo()
leftGrid.Write(2, 1, "OS: %s", info.OS)
leftGrid.Write(3, 1, "Kernel: %s", info.Kernel)
leftGrid.Write(4, 1, "Arch: %s", info.Arch)
leftGrid.Write(6, 1, "Hostname: %s", info.Hostname)
leftGrid.Write(7, 1, "Uptime: %s", info.Uptime)
// Right panel - Performance
rightGrid := d.Grid(d.Width/2+20, 120, 40, 25)
rightGrid.Background(fbdraw.Gray20)
rightGrid.Border(fbdraw.Gray40)
rightGrid.Color(fbdraw.Orange).Bold()
rightGrid.WriteCenter(0, "PERFORMANCE")
rightGrid.Plain()
// CPU bars
rightGrid.Color(fbdraw.Blue)
cpus := getCPUCores()
for i, cpu := range cpus {
rightGrid.Write(2+i, 1, "CPU%d", i)
rightGrid.Bar(2+i, 6, 30, cpu.Percent, fbdraw.Heat(cpu.Percent))
}
// Memory meter
rightGrid.Color(fbdraw.Purple)
mem := getMemory()
rightGrid.Write(12, 1, "Memory")
rightGrid.Bar(12, 8, 28, mem.Percent, fbdraw.Green)
rightGrid.Write(13, 8, "%s / %s",
fbdraw.Bytes(mem.Used), fbdraw.Bytes(mem.Total))
// Bottom status bar
statusGrid := d.Grid(0, d.Height-40, d.Width/12, 1)
statusGrid.Background(fbdraw.Black)
statusGrid.Color(fbdraw.Gray60).Size(12)
statusGrid.Write(0, 1, "Updated: %s | Load: %.2f | Temp: %d°C | Net: %s",
time.Now().Format("15:04:05"),
getLoad1Min(),
getCPUTemp(),
getNetRate())
}
```
### Process Table Example
```go
func renderProcessTable(d *fbdraw.Draw) {
d.Clear()
// Header
d.Font(fbdraw.PlexSans).Size(24).Bold()
d.Text(20, 30, "Top Processes by CPU")
// Create table grid
table := d.Grid(20, 70, 100, 40)
table.Font(fbdraw.PlexMono).Size(14)
// Table headers
table.Background(fbdraw.Gray30).Color(fbdraw.White).Bold()
table.Write(0, 0, "PID")
table.Write(0, 8, "USER")
table.Write(0, 20, "PROCESS")
table.Write(0, 60, "CPU%")
table.Write(0, 70, "MEM%")
table.Write(0, 80, "TIME")
// Reset style for data
table.Background(fbdraw.Black).Plain()
// Process rows
processes := getTopProcesses(38) // 38 rows of data
for i, p := range processes {
row := i + 2 // Skip header row and blank row
// Alternate row backgrounds
if i%2 == 0 {
table.RowBackground(row, fbdraw.Gray10)
}
// Highlight high CPU usage
if p.CPU > 50.0 {
table.RowColor(row, fbdraw.Red)
} else if p.CPU > 20.0 {
table.RowColor(row, fbdraw.Yellow)
} else {
table.RowColor(row, fbdraw.Gray80)
}
table.Write(row, 0, "%5d", p.PID)
table.Write(row, 8, "%-8s", p.User)
table.Write(row, 20, "%-38s", truncate(p.Name, 38))
table.Write(row, 60, "%5.1f", p.CPU)
table.Write(row, 70, "%5.1f", p.Memory)
table.Write(row, 80, "%8s", p.Time)
}
}
```
## Key Features
### Bundled Fonts
```go
const (
PlexMono = iota // IBM Plex Mono - great for tables/code
PlexSans // IBM Plex Sans - clean headers
Terminus // Terminus - sharp bitmap font
)
```
### Drawing State
The Draw object maintains state between calls:
- Current font, size, bold/italic
- Foreground and background colors
- Transformations
### Text Grids
- Define rectangular regions with rows/columns
- Automatic text positioning
- Built-in backgrounds, borders, styling
- Row/column operations
### Utilities
```go
// Format bytes nicely (1.2GB, 456MB, etc)
fbdraw.Bytes(1234567890) // "1.2GB"
// Create text-based meter/progress bars
fbdraw.Meter(75.5, 20) // "███████████████ "
// Color based on value
fbdraw.Heat(temp) // Returns color from blue->green->yellow->red
// Truncate with ellipsis
truncate("very long string", 10) // "very lo..."
```
### Colors
```go
// Basic colors
fbdraw.Black, White, Red, Green, Blue, Yellow, Cyan, Purple, Orange
// Grays
fbdraw.Gray10, Gray20, Gray30, Gray40, Gray50, Gray60, Gray70, Gray80, Gray90
// Custom
fbdraw.RGB(128, 200, 255)
fbdraw.HSL(180, 0.5, 0.5)
```
## Carousel API
The carousel system provides automatic screen rotation with independent frame rates for each screen.
### Core Interfaces
```go
// FrameGenerator generates frames for a screen
type FrameGenerator interface {
// GenerateFrame is called to render a new frame
GenerateFrame(grid *Grid) error
// FramesPerSecond returns the desired frame rate
FramesPerSecond() float64
}
// Display represents the output device (framebuffer, terminal, etc)
type Display interface {
// Write renders a grid to the display
Write(grid *Grid) error
// Size returns the display dimensions in characters
Size() (width, height int)
// Close cleans up resources
Close() error
}
```
### Carousel Usage
```go
// Create display
display, err := fbdraw.NewDisplay("") // auto-detect
// Create carousel with rotation interval
carousel := fbdraw.NewCarousel(display, 10*time.Second)
// Add screens
carousel.AddScreen("System Status", &SystemStatusGenerator{})
carousel.AddScreen("Network", &NetworkMonitorGenerator{})
carousel.AddScreen("Processes", &ProcessListGenerator{})
// Run carousel (blocks)
carousel.Run()
```
### Screen Implementation
```go
type MyScreenGenerator struct {
// Internal state
}
func (g *MyScreenGenerator) GenerateFrame(grid *Grid) error {
w := NewGridWriter(grid)
w.Clear()
// Draw your content
w.MoveTo(0, 0).Write("Hello World")
return nil
}
func (g *MyScreenGenerator) FramesPerSecond() float64 {
return 10.0 // 10 FPS
}
```
### Features
- **Independent frame rates**: Each screen can update at its own rate
- **Automatic rotation**: Screens rotate on a timer
- **Clean separation**: Generators just draw, carousel handles timing
- **Resource efficient**: Only the active screen generates frames
- **Graceful shutdown**: Handles signals properly
## Design Philosophy
- **Immediate mode**: Draw calls happen immediately in your render function
- **Stateful**: Common properties persist until changed
- **Grid-based**: Most text UIs are grids - embrace it
- **Batteries included**: Common fonts, colors, and utilities built-in
- **Zero allocation**: Reuse buffers where possible for smooth updates
- **Simple interfaces**: Easy to implement custom screens

162
internal/fbdraw/carousel.go Normal file
View File

@ -0,0 +1,162 @@
package fbdraw
import (
"context"
"fmt"
"sync"
"time"
)
// Screen represents a single screen in the carousel
type Screen struct {
Name string
Generator FrameGenerator
ticker *time.Ticker
stop chan struct{}
}
// Carousel manages rotating between multiple screens
type Carousel struct {
display FramebufferDisplay
screens []*Screen
currentIndex int
rotationInterval time.Duration
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
}
// NewCarousel creates a new carousel
func NewCarousel(display FramebufferDisplay, rotationInterval time.Duration) *Carousel {
ctx, cancel := context.WithCancel(context.Background())
return &Carousel{
display: display,
screens: make([]*Screen, 0),
currentIndex: 0,
rotationInterval: rotationInterval,
ctx: ctx,
cancel: cancel,
}
}
// AddScreen adds a new screen to the carousel
func (c *Carousel) AddScreen(name string, generator FrameGenerator) {
c.mu.Lock()
defer c.mu.Unlock()
screen := &Screen{
Name: name,
Generator: generator,
stop: make(chan struct{}),
}
c.screens = append(c.screens, screen)
}
// Run starts the carousel
func (c *Carousel) Run() error {
if len(c.screens) == 0 {
return fmt.Errorf("no screens added to carousel")
}
// Start rotation timer
rotationTicker := time.NewTicker(c.rotationInterval)
defer rotationTicker.Stop()
// Start with first screen
if err := c.activateScreen(0); err != nil {
return err
}
// Main loop
for {
select {
case <-c.ctx.Done():
return c.ctx.Err()
case <-rotationTicker.C:
// Move to next screen
c.mu.Lock()
nextIndex := (c.currentIndex + 1) % len(c.screens)
c.mu.Unlock()
if err := c.activateScreen(nextIndex); err != nil {
return err
}
}
}
}
// Stop stops the carousel
func (c *Carousel) Stop() {
c.cancel()
c.wg.Wait()
}
// activateScreen switches to the specified screen
func (c *Carousel) activateScreen(index int) error {
c.mu.Lock()
defer c.mu.Unlock()
// Stop current screen if any
if c.currentIndex >= 0 && c.currentIndex < len(c.screens) {
close(c.screens[c.currentIndex].stop)
if c.screens[c.currentIndex].ticker != nil {
c.screens[c.currentIndex].ticker.Stop()
}
}
// Wait for current screen to stop
c.wg.Wait()
// Start new screen
c.currentIndex = index
screen := c.screens[index]
// Calculate frame interval
fps := screen.Generator.FramesPerSecond()
if fps <= 0 {
fps = 1 // Default to 1 FPS if invalid
}
frameInterval := time.Duration(float64(time.Second) / fps)
// Create new stop channel and ticker
screen.stop = make(chan struct{})
screen.ticker = time.NewTicker(frameInterval)
// Start frame generation goroutine
c.wg.Add(1)
go c.runScreen(screen)
return nil
}
// runScreen runs a single screen's frame generation loop
func (c *Carousel) runScreen(screen *Screen) {
defer c.wg.Done()
// Get display size
width, height := c.display.Size()
grid := NewCharGrid(width, height)
// Generate first frame immediately
if err := screen.Generator.GenerateFrame(grid); err == nil {
_ = c.display.Write(grid)
}
// Frame generation loop
for {
select {
case <-screen.stop:
return
case <-screen.ticker.C:
if err := screen.Generator.GenerateFrame(grid); err == nil {
_ = c.display.Write(grid)
}
}
}
}

309
internal/fbdraw/display.go Normal file
View File

@ -0,0 +1,309 @@
package fbdraw
import (
"fmt"
"log"
"os"
"syscall"
"unsafe"
)
// FBDisplay renders to a Linux framebuffer device
type FBDisplay struct {
device string
file *os.File
info fbVarScreeninfo
fixInfo fbFixScreeninfo
data []byte
charWidth int
charHeight int
}
// fbFixScreeninfo from linux/fb.h
type fbFixScreeninfo struct {
ID [16]byte
SMEMStart uint64
SMEMLen uint32
Type uint32
TypeAux uint32
Visual uint32
XPanStep uint16
YPanStep uint16
YWrapStep uint16
_ uint16
LineLength uint32
MMIOStart uint64
MMIOLen uint32
Accel uint32
Capabilities uint16
Reserved [2]uint16
}
// fbVarScreeninfo from linux/fb.h
type fbVarScreeninfo struct {
XRes uint32
YRes uint32
XResVirtual uint32
YResVirtual uint32
XOffset uint32
YOffset uint32
BitsPerPixel uint32
Grayscale uint32
Red fbBitfield
Green fbBitfield
Blue fbBitfield
Transp fbBitfield
NonStd uint32
Activate uint32
Height uint32
Width uint32
AccelFlags uint32
PixClock uint32
LeftMargin uint32
RightMargin uint32
UpperMargin uint32
LowerMargin uint32
HSyncLen uint32
VSyncLen uint32
Sync uint32
VMode uint32
Rotate uint32
Colorspace uint32
Reserved [4]uint32
}
type fbBitfield struct {
Offset uint32
Length uint32
Right uint32
}
const (
fbiogetVscreeninfo = 0x4600
fbiogetFscreeninfo = 0x4602
)
// NewFBDisplay creates a display for a specific framebuffer device
func NewFBDisplay(device string) (*FBDisplay, error) {
file, err := os.OpenFile(device, os.O_RDWR, 0) //nolint:gosec
if err != nil {
return nil, fmt.Errorf("opening framebuffer %s: %w", device, err)
}
display := &FBDisplay{
device: device,
file: file,
}
// Get variable screen info
if err := display.getScreenInfo(); err != nil {
_ = file.Close()
return nil, err
}
// Get fixed screen info
if err := display.getFixedInfo(); err != nil {
_ = file.Close()
return nil, err
}
// Memory map the framebuffer
size := int(display.fixInfo.SMEMLen)
display.data, err = syscall.Mmap(
int(file.Fd()),
0,
size,
syscall.PROT_READ|syscall.PROT_WRITE,
syscall.MAP_SHARED,
)
if err != nil {
_ = file.Close()
return nil, fmt.Errorf("mmap framebuffer: %w", err)
}
// Calculate character dimensions (rough approximation)
display.charWidth = 8
display.charHeight = 16
return display, nil
}
// NewFBDisplayAuto auto-detects and opens the framebuffer
func NewFBDisplayAuto() (*FBDisplay, error) {
// Try common framebuffer devices
devices := []string{"/dev/fb0", "/dev/fb1", "/dev/graphics/fb0"}
for _, device := range devices {
if _, err := os.Stat(device); err == nil {
display, err := NewFBDisplay(device)
if err == nil {
return display, nil
}
}
}
return nil, fmt.Errorf("no framebuffer device found")
}
func (d *FBDisplay) getScreenInfo() error {
_, _, errno := syscall.Syscall(
syscall.SYS_IOCTL,
d.file.Fd(),
fbiogetVscreeninfo,
uintptr(unsafe.Pointer(&d.info)), //nolint:gosec
)
if errno != 0 {
return fmt.Errorf("ioctl FBIOGET_VSCREENINFO: %w", errno)
}
return nil
}
func (d *FBDisplay) getFixedInfo() error {
_, _, errno := syscall.Syscall(
syscall.SYS_IOCTL,
d.file.Fd(),
fbiogetFscreeninfo,
uintptr(unsafe.Pointer(&d.fixInfo)), //nolint:gosec
)
if errno != 0 {
return fmt.Errorf("ioctl FBIOGET_FSCREENINFO: %w", errno)
}
return nil
}
// Write renders a grid to the framebuffer
func (d *FBDisplay) Write(grid *CharGrid) error {
// Render grid to image
img, err := grid.Render()
if err != nil {
return fmt.Errorf("rendering grid: %w", err)
}
// Clear framebuffer
for i := range d.data {
d.data[i] = 0
}
// Copy image to framebuffer
bounds := img.Bounds()
bytesPerPixel := int(d.info.BitsPerPixel / 8)
lineLength := int(d.fixInfo.LineLength)
for y := bounds.Min.Y; y < bounds.Max.Y && y < int(d.info.YRes); y++ {
for x := bounds.Min.X; x < bounds.Max.X && x < int(d.info.XRes); x++ {
r, g, b, _ := img.At(x, y).RGBA()
offset := y*lineLength + x*bytesPerPixel
if offset+bytesPerPixel <= len(d.data) {
// Assuming 32-bit BGRA format (most common)
if bytesPerPixel == 4 {
d.data[offset+0] = byte(b >> 8)
d.data[offset+1] = byte(g >> 8)
d.data[offset+2] = byte(r >> 8)
d.data[offset+3] = 0xFF
}
}
}
}
return nil
}
// Size returns the display size in characters
func (d *FBDisplay) Size() (width, height int) {
width = int(d.info.XRes) / d.charWidth
height = int(d.info.YRes) / d.charHeight
return
}
// Close closes the framebuffer
func (d *FBDisplay) Close() error {
if d.data != nil {
if err := syscall.Munmap(d.data); err != nil {
log.Printf("munmap error: %v", err)
}
d.data = nil
}
if d.file != nil {
if err := d.file.Close(); err != nil {
return err
}
d.file = nil
}
return nil
}
// TerminalDisplay renders to the terminal using ANSI escape codes
type TerminalDisplay struct {
width int
height int
}
// NewTerminalDisplay creates a terminal display
func NewTerminalDisplay(width, height int) *TerminalDisplay {
return &TerminalDisplay{
width: width,
height: height,
}
}
// Write renders a grid to the terminal
func (d *TerminalDisplay) Write(grid *CharGrid) error {
// Clear screen
fmt.Print("\033[2J\033[H")
// Print ANSI representation
fmt.Print(grid.ToANSI())
return nil
}
// Size returns the terminal size in characters
func (d *TerminalDisplay) Size() (width, height int) {
return d.width, d.height
}
// Close is a no-op for terminal display
func (d *TerminalDisplay) Close() error {
// Clear screen one last time
fmt.Print("\033[2J\033[H")
return nil
}
// LogDisplay renders to a logger for debugging
type LogDisplay struct {
width int
height int
logger *log.Logger
}
// NewLogDisplay creates a log display
func NewLogDisplay(width, height int, logger *log.Logger) *LogDisplay {
if logger == nil {
logger = log.New(os.Stderr, "[fbdraw] ", log.LstdFlags)
}
return &LogDisplay{
width: width,
height: height,
logger: logger,
}
}
// Write logs the grid as text
func (d *LogDisplay) Write(grid *CharGrid) error {
d.logger.Printf("=== Frame ===\n%s\n", grid.ToText())
return nil
}
// Size returns the display size
func (d *LogDisplay) Size() (width, height int) {
return d.width, d.height
}
// Close is a no-op for log display
func (d *LogDisplay) Close() error {
return nil
}

76
internal/fbdraw/doc.go Normal file
View File

@ -0,0 +1,76 @@
/*
Package fbdraw provides a simple, immediate-mode API for rendering
monospaced text to Linux framebuffers.
# Basic Usage
The simplest way to create a display:
display, err := fbdraw.Init()
if err != nil {
log.Fatal(err)
}
defer display.Close()
display.Loop(func(d *fbdraw.Draw) {
d.Clear()
d.Font(fbdraw.PlexMono).Size(24).Bold()
d.Color(fbdraw.White)
d.TextCenter(d.Width/2, 50, "Hello, Framebuffer!")
d.Present()
})
# Working with Grids
For structured layouts, use the grid system:
grid := d.Grid(10, 10, 80, 25) // x, y, columns, rows
grid.Border(fbdraw.White)
grid.Color(fbdraw.Green).Bold()
grid.WriteCenter(0, "System Status")
grid.Plain().Color(fbdraw.White)
grid.Write(2, 0, "CPU:")
grid.Bar(2, 10, 30, cpuPercent, fbdraw.Heat(cpuPercent))
# Drawing State
The Draw object maintains state between calls:
d.Font(fbdraw.PlexMono).Size(14).Bold()
d.Color(fbdraw.Green)
d.Text(10, 10, "This is green bold text")
d.Text(10, 30, "This is still green bold text")
d.Plain().Color(fbdraw.White)
d.Text(10, 50, "This is white plain text")
# Performance
The package is designed for status displays that update at most a few times
per second. It prioritizes ease of use over performance. Each Present() call
updates the entire framebuffer.
# Fonts
Three monospace fonts are bundled:
- PlexMono: IBM Plex Mono, excellent readability
- Terminus: Classic bitmap terminal font
- SourceCodePro: Adobe's coding font
# Colors
Common colors are provided as package variables (Black, White, Red, etc).
Grays are available from Gray10 (darkest) to Gray90 (lightest).
# Error Handling
Most operations fail silently to keep the API simple. Use explicit error
checks where needed:
if err := d.Present(); err != nil {
// Handle framebuffer write error
}
*/
package fbdraw

490
internal/fbdraw/grid.go Normal file
View File

@ -0,0 +1,490 @@
package fbdraw
import (
"fmt"
"image"
"image/color"
"strings"
"sync"
"git.eeqj.de/sneak/hdmistat/internal/font"
"github.com/golang/freetype"
"github.com/golang/freetype/truetype"
)
// Common colors
//
//nolint:gochecknoglobals
var (
Black = color.RGBA{0, 0, 0, 255}
White = color.RGBA{255, 255, 255, 255}
Red = color.RGBA{255, 0, 0, 255}
Green = color.RGBA{0, 255, 0, 255}
Blue = color.RGBA{0, 0, 255, 255}
Yellow = color.RGBA{255, 255, 0, 255}
Cyan = color.RGBA{0, 255, 255, 255}
Magenta = color.RGBA{255, 0, 255, 255}
Orange = color.RGBA{255, 165, 0, 255}
Purple = color.RGBA{128, 0, 128, 255}
// Grays
Gray10 = color.RGBA{26, 26, 26, 255}
Gray20 = color.RGBA{51, 51, 51, 255}
Gray30 = color.RGBA{77, 77, 77, 255}
Gray40 = color.RGBA{102, 102, 102, 255}
Gray50 = color.RGBA{128, 128, 128, 255}
Gray60 = color.RGBA{153, 153, 153, 255}
Gray70 = color.RGBA{179, 179, 179, 255}
Gray80 = color.RGBA{204, 204, 204, 255}
Gray90 = color.RGBA{230, 230, 230, 255}
)
// Cell represents a single character cell in the grid
type Cell struct {
Rune rune
Foreground color.Color
Background color.Color
Weight font.FontWeight
Italic bool
}
// CharGrid represents a monospace character grid
type CharGrid struct {
Width int // Width in characters
Height int // Height in characters
Cells [][]Cell // 2D array [y][x]
// Font settings
FontFamily font.FontFamily
FontSize float64 // Points
// Computed values
CharWidth int // Pixel width of a character
CharHeight int // Pixel height of a character
// Rendering cache
fontCache map[fontKey]*truetype.Font
mu sync.RWMutex
}
type fontKey struct {
family font.FontFamily
weight font.FontWeight
italic bool
}
// NewCharGrid creates a new character grid
func NewCharGrid(width, height int) *CharGrid {
// Create 2D array
cells := make([][]Cell, height)
for y := 0; y < height; y++ {
cells[y] = make([]Cell, width)
// Initialize with spaces and default colors
for x := 0; x < width; x++ {
cells[y][x] = Cell{
Rune: ' ',
Foreground: White,
Background: Black,
Weight: font.WeightRegular,
Italic: false,
}
}
}
return &CharGrid{
Width: width,
Height: height,
Cells: cells,
FontFamily: font.FamilyIBMPlexMono,
FontSize: 14,
CharWidth: 8, // Will be computed based on font
CharHeight: 16, // Will be computed based on font
fontCache: make(map[fontKey]*truetype.Font),
}
}
// SetCell sets a single cell's content
func (g *CharGrid) SetCell(x, y int, r rune, fg, bg color.Color, weight font.FontWeight, italic bool) {
if x < 0 || x >= g.Width || y < 0 || y >= g.Height {
return
}
g.Cells[y][x] = Cell{
Rune: r,
Foreground: fg,
Background: bg,
Weight: weight,
Italic: italic,
}
}
// WriteString writes a string starting at position (x, y)
func (g *CharGrid) WriteString(x, y int, s string, fg, bg color.Color, weight font.FontWeight, italic bool) {
runes := []rune(s)
for i, r := range runes {
g.SetCell(x+i, y, r, fg, bg, weight, italic)
}
}
// Clear clears the grid with the specified background color
func (g *CharGrid) Clear(bg color.Color) {
for y := 0; y < g.Height; y++ {
for x := 0; x < g.Width; x++ {
g.Cells[y][x] = Cell{
Rune: ' ',
Foreground: White,
Background: bg,
Weight: font.WeightRegular,
Italic: false,
}
}
}
}
// getFont retrieves a font from cache or loads it
func (g *CharGrid) getFont(weight font.FontWeight, italic bool) (*truetype.Font, error) {
key := fontKey{
family: g.FontFamily,
weight: weight,
italic: italic,
}
g.mu.RLock()
if f, ok := g.fontCache[key]; ok {
g.mu.RUnlock()
return f, nil
}
g.mu.RUnlock()
// Load font
g.mu.Lock()
defer g.mu.Unlock()
// Double-check after acquiring write lock
if f, ok := g.fontCache[key]; ok {
return f, nil
}
f, err := font.LoadFont(g.FontFamily, weight, italic)
if err != nil {
return nil, err
}
g.fontCache[key] = f
return f, nil
}
// computeCharSize computes the character cell size based on font metrics
func (g *CharGrid) computeCharSize() error {
f, err := g.getFont(font.WeightRegular, false)
if err != nil {
return err
}
// Use freetype to measure a typical character
opts := truetype.Options{
Size: g.FontSize,
DPI: 72,
}
face := truetype.NewFace(f, &opts)
// Measure 'M' for width (typically widest regular character in monospace)
bounds, _, _ := face.GlyphBounds('M')
g.CharWidth = (bounds.Max.X - bounds.Min.X).Round()
// Use font metrics for height
metrics := face.Metrics()
g.CharHeight = (metrics.Ascent + metrics.Descent).Round()
return nil
}
// Render renders the grid to an image
func (g *CharGrid) Render() (*image.RGBA, error) {
// Ensure character dimensions are computed
if err := g.computeCharSize(); err != nil {
return nil, err
}
// Create image
width := g.Width * g.CharWidth
height := g.Height * g.CharHeight
img := image.NewRGBA(image.Rect(0, 0, width, height))
// First pass: draw backgrounds
for y := 0; y < g.Height; y++ {
for x := 0; x < g.Width; x++ {
cell := g.Cells[y][x]
// Draw background rectangle
x0 := x * g.CharWidth
y0 := y * g.CharHeight
x1 := x0 + g.CharWidth
y1 := y0 + g.CharHeight
for py := y0; py < y1; py++ {
for px := x0; px < x1; px++ {
img.Set(px, py, cell.Background)
}
}
}
}
// Second pass: draw text
ctx := freetype.NewContext()
ctx.SetDPI(72)
ctx.SetFont(nil) // Will be set per cell
ctx.SetFontSize(g.FontSize)
ctx.SetClip(img.Bounds())
ctx.SetDst(img)
for y := 0; y < g.Height; y++ {
for x := 0; x < g.Width; x++ {
cell := g.Cells[y][x]
if cell.Rune == ' ' {
continue // Skip spaces
}
// Get font for this cell
f, err := g.getFont(cell.Weight, cell.Italic)
if err != nil {
continue // Skip cells with font errors
}
ctx.SetFont(f)
ctx.SetSrc(image.NewUniform(cell.Foreground))
// Calculate text position
// X: left edge of cell
// Y: baseline (ascent from top of cell)
opts := truetype.Options{
Size: g.FontSize,
DPI: 72,
}
face := truetype.NewFace(f, &opts)
metrics := face.Metrics()
px := x * g.CharWidth
py := y*g.CharHeight + metrics.Ascent.Round()
pt := freetype.Pt(px, py)
_, _ = ctx.DrawString(string(cell.Rune), pt)
}
}
return img, nil
}
// ToText renders the grid as text for debugging/logging
func (g *CharGrid) ToText() string {
var sb strings.Builder
for y := 0; y < g.Height; y++ {
for x := 0; x < g.Width; x++ {
sb.WriteRune(g.Cells[y][x].Rune)
}
if y < g.Height-1 {
sb.WriteRune('\n')
}
}
return sb.String()
}
// ToANSI renders the grid as ANSI escape sequences for terminal display
func (g *CharGrid) ToANSI() string {
var sb strings.Builder
// Track last colors to minimize escape sequences
var lastFg, lastBg color.Color
var lastWeight font.FontWeight
var lastItalic bool
for y := 0; y < g.Height; y++ {
for x := 0; x < g.Width; x++ {
cell := g.Cells[y][x]
// Update styles if changed
if cell.Foreground != lastFg || cell.Background != lastBg ||
cell.Weight != lastWeight || cell.Italic != lastItalic {
// Reset
sb.WriteString("\033[0m")
// Weight
if cell.Weight == font.WeightBold ||
cell.Weight == font.WeightExtraBold ||
cell.Weight == font.WeightBlack {
sb.WriteString("\033[1m") // Bold
}
// Italic
if cell.Italic {
sb.WriteString("\033[3m")
}
// Foreground color
if r, g, b, _ := cell.Foreground.RGBA(); r != 0 || g != 0 || b != 0 {
sb.WriteString(fmt.Sprintf("\033[38;2;%d;%d;%dm",
r>>8, g>>8, b>>8))
}
// Background color
if r, g, b, _ := cell.Background.RGBA(); r != 0 || g != 0 || b != 0 {
sb.WriteString(fmt.Sprintf("\033[48;2;%d;%d;%dm",
r>>8, g>>8, b>>8))
}
lastFg = cell.Foreground
lastBg = cell.Background
lastWeight = cell.Weight
lastItalic = cell.Italic
}
sb.WriteRune(cell.Rune)
}
// Reset at end of line and add newline
sb.WriteString("\033[0m\n")
lastFg = nil
lastBg = nil
lastWeight = ""
lastItalic = false
}
// Final reset
sb.WriteString("\033[0m")
return sb.String()
}
// GridWriter provides a convenient API for writing to a grid
type GridWriter struct {
Grid *CharGrid
X, Y int // Current position
Foreground color.Color
Background color.Color
Weight font.FontWeight
Italic bool
}
// NewGridWriter creates a new GridWriter
func NewGridWriter(grid *CharGrid) *GridWriter {
return &GridWriter{
Grid: grid,
X: 0,
Y: 0,
Foreground: White,
Background: Black,
Weight: font.WeightRegular,
Italic: false,
}
}
// MoveAbs moves the cursor to an absolute position
func (w *GridWriter) MoveAbs(x, y int) *GridWriter {
w.X = x
w.Y = y
return w
}
// Move moves the cursor relative to the current position
func (w *GridWriter) Move(dx, dy int) *GridWriter {
w.X += dx
w.Y += dy
return w
}
// SetColor sets the foreground color
func (w *GridWriter) SetColor(c color.Color) *GridWriter {
w.Foreground = c
return w
}
// SetBackground sets the background color
func (w *GridWriter) SetBackground(c color.Color) *GridWriter {
w.Background = c
return w
}
// SetWeight sets the font weight
func (w *GridWriter) SetWeight(weight font.FontWeight) *GridWriter {
w.Weight = weight
return w
}
// SetItalic sets italic style
func (w *GridWriter) SetItalic(italic bool) *GridWriter {
w.Italic = italic
return w
}
// Write writes a string at the current position
func (w *GridWriter) Write(format string, args ...interface{}) *GridWriter {
s := fmt.Sprintf(format, args...)
w.Grid.WriteString(w.X, w.Y, s, w.Foreground, w.Background, w.Weight, w.Italic)
w.X += len([]rune(s))
return w
}
// WriteLine writes a string and moves to the next line
func (w *GridWriter) WriteLine(format string, args ...interface{}) *GridWriter {
w.Write(format, args...)
w.X = 0
w.Y++
return w
}
// NewLine moves to the next line
func (w *GridWriter) NewLine() *GridWriter {
w.X = 0
w.Y++
return w
}
// Clear clears the grid with the current background color
func (w *GridWriter) Clear() *GridWriter {
w.Grid.Clear(w.Background)
w.X = 0
w.Y = 0
return w
}
// DrawMeter draws a progress meter at the current position
func (w *GridWriter) DrawMeter(percent float64, width int) *GridWriter {
if percent < 0 {
percent = 0
}
if percent > 100 {
percent = 100
}
filled := int(percent / 100.0 * float64(width))
// Save original color
origColor := w.Foreground
// Set color based on percentage
if percent > 80 {
w.SetColor(Red)
} else if percent > 50 {
w.SetColor(Yellow)
} else {
w.SetColor(Green)
}
// Draw the meter
for i := 0; i < width; i++ {
if i < filled {
w.Write("█")
} else {
w.Write("▒")
}
}
// Restore original color
w.SetColor(origColor)
return w
}

View File

@ -0,0 +1,160 @@
package fbdraw
import (
"fmt"
"image/color"
"testing"
"git.eeqj.de/sneak/hdmistat/internal/font"
)
func ExampleGrid() {
// Create a 80x25 character grid (standard terminal size)
grid := NewGrid(80, 25)
// Create a writer for convenience
w := NewGridWriter(grid)
// Clear with dark background
w.SetBackground(Gray10).Clear()
// Draw header
w.MoveTo(0, 0).
SetBackground(Blue).
SetColor(White).
SetWeight(font.WeightBold).
WriteLine(" System Monitor v1.0 ")
// Reset to normal style
w.SetBackground(Black).SetWeight(font.WeightRegular)
// System info section
w.MoveTo(2, 2).
SetColor(Green).SetWeight(font.WeightBold).
Write("SYSTEM INFO").
SetWeight(font.WeightRegular).SetColor(White)
w.MoveTo(2, 4).Write("Hostname: ").SetColor(Cyan).Write("server01.example.com")
w.MoveTo(2, 5).SetColor(White).Write("Uptime: ").SetColor(Yellow).Write("14 days, 3:42:15")
w.MoveTo(2, 6).SetColor(White).Write("Load: ").SetColor(Red).Write("2.45 1.82 1.65")
// CPU meters
w.MoveTo(2, 8).SetColor(Blue).SetWeight(font.WeightBold).Write("CPU USAGE")
w.SetWeight(font.WeightRegular)
cpuValues := []float64{45.2, 78.9, 23.4, 91.5}
for i, cpu := range cpuValues {
w.MoveTo(2, 10+i)
w.SetColor(White).Write("CPU%d [", i)
// Draw meter
meterWidth := 20
filled := int(cpu / 100.0 * float64(meterWidth))
// Choose color based on usage
if cpu > 80 {
w.SetColor(Red)
} else if cpu > 50 {
w.SetColor(Yellow)
} else {
w.SetColor(Green)
}
for j := 0; j < meterWidth; j++ {
if j < filled {
w.Write("█")
} else {
w.Write("░")
}
}
w.SetColor(White).Write("] %5.1f%%", cpu)
}
// Memory section
w.MoveTo(45, 8).SetColor(Purple).SetWeight(font.WeightBold).Write("MEMORY")
w.SetWeight(font.WeightRegular).SetColor(White)
w.MoveTo(45, 10).Write("Used: ").SetColor(Green).Write("4.2 GB / 16.0 GB")
w.MoveTo(45, 11).SetColor(White).Write("Free: ").SetColor(Green).Write("11.8 GB")
w.MoveTo(45, 12).SetColor(White).Write("Cache: ").SetColor(Blue).Write("2.1 GB")
// Process table
w.MoveTo(2, 16).SetColor(Orange).SetWeight(font.WeightBold).Write("TOP PROCESSES")
w.SetWeight(font.WeightRegular)
// Table header
w.MoveTo(2, 18).SetBackground(Gray30).SetColor(White).SetWeight(font.WeightBold)
w.Write(" PID USER PROCESS CPU% MEM% ")
w.SetBackground(Black).SetWeight(font.WeightRegular)
// Table rows
processes := []struct {
pid int
user string
name string
cpu float64
mem float64
}{
{1234, "root", "systemd", 0.2, 0.1},
{5678, "user", "firefox", 15.8, 12.4},
{9012, "user", "vscode", 42.1, 8.7},
}
for i, p := range processes {
y := 19 + i
// Alternate row backgrounds
if i%2 == 0 {
w.SetBackground(Gray10)
} else {
w.SetBackground(Black)
}
// Highlight high CPU
if p.cpu > 40 {
w.SetColor(Red)
} else if p.cpu > 20 {
w.SetColor(Yellow)
} else {
w.SetColor(Gray70)
}
w.MoveTo(0, y)
w.Write(" %5d %-8s %-25s %5.1f %5.1f ",
p.pid, p.user, p.name, p.cpu, p.mem)
}
// Output as text
fmt.Println(grid.ToText())
}
func TestGrid(t *testing.T) {
grid := NewGrid(40, 10)
w := NewGridWriter(grid)
// Test basic writing
w.MoveTo(5, 2).Write("Hello, World!")
// Test colors and styles
w.MoveTo(5, 4).
SetColor(Red).SetWeight(font.WeightBold).
Write("Bold Red Text")
// Test unicode
w.MoveTo(5, 6).
SetColor(Green).
Write("Progress: [████████▒▒▒▒▒▒▒▒] 50%")
// Check text output
text := grid.ToText()
if text == "" {
t.Error("Grid should not be empty")
}
// Check specific cell
cell := grid.Cells[2][5] // Row 2, Column 5 should be 'H'
if cell.Rune != 'H' {
t.Errorf("Expected 'H' at (5,2), got '%c'", cell.Rune)
}
}

View File

@ -0,0 +1,22 @@
package fbdraw
// FrameGenerator generates frames for a screen
type FrameGenerator interface {
// GenerateFrame is called to render a new frame
GenerateFrame(grid *CharGrid) error
// FramesPerSecond returns the desired frame rate
FramesPerSecond() float64
}
// FramebufferDisplay interface represents the output device
type FramebufferDisplay interface {
// Write renders a grid to the display
Write(grid *CharGrid) error
// Size returns the display dimensions in characters
Size() (width, height int)
// Close cleans up resources
Close() error
}

View File

@ -1,3 +1,4 @@
// Package font provides embedded font resources for hdmistat
package font
import (
@ -7,38 +8,260 @@ import (
"github.com/golang/freetype/truetype"
)
// IBM Plex Mono fonts
//
//go:embed fonts/IBM_Plex_Mono/IBMPlexMono-Thin.ttf
var ibmPlexMonoThin []byte
//go:embed fonts/IBM_Plex_Mono/IBMPlexMono-ThinItalic.ttf
var ibmPlexMonoThinItalic []byte
//go:embed fonts/IBM_Plex_Mono/IBMPlexMono-ExtraLight.ttf
var ibmPlexMonoExtraLight []byte
//go:embed fonts/IBM_Plex_Mono/IBMPlexMono-ExtraLightItalic.ttf
var ibmPlexMonoExtraLightItalic []byte
//go:embed fonts/IBM_Plex_Mono/IBMPlexMono-Light.ttf
var ibmPlexMonoLight []byte
//go:embed fonts/IBM_Plex_Mono/IBMPlexMono-LightItalic.ttf
var ibmPlexMonoLightItalic []byte
//go:embed fonts/IBM_Plex_Mono/IBMPlexMono-Regular.ttf
var ibmPlexMonoRegular []byte
//go:embed fonts/IBM_Plex_Mono/IBMPlexMono-Italic.ttf
var ibmPlexMonoItalic []byte
//go:embed fonts/IBM_Plex_Mono/IBMPlexMono-Medium.ttf
var ibmPlexMonoMedium []byte
//go:embed fonts/IBM_Plex_Mono/IBMPlexMono-MediumItalic.ttf
var ibmPlexMonoMediumItalic []byte
//go:embed fonts/IBM_Plex_Mono/IBMPlexMono-SemiBold.ttf
var ibmPlexMonoSemiBold []byte
//go:embed fonts/IBM_Plex_Mono/IBMPlexMono-SemiBoldItalic.ttf
var ibmPlexMonoSemiBoldItalic []byte
//go:embed fonts/IBM_Plex_Mono/IBMPlexMono-Bold.ttf
var ibmPlexMonoBold []byte
// LoadIBMPlexMono loads the embedded IBM Plex Mono font (Light weight)
func LoadIBMPlexMono() (*truetype.Font, error) {
font, err := truetype.Parse(ibmPlexMonoLight)
//go:embed fonts/IBM_Plex_Mono/IBMPlexMono-BoldItalic.ttf
var ibmPlexMonoBoldItalic []byte
// Source Code Pro fonts
//
//go:embed fonts/Source_Code_Pro/SourceCodePro-ExtraLight.ttf
var sourceCodeProExtraLight []byte
//go:embed fonts/Source_Code_Pro/SourceCodePro-ExtraLightItalic.ttf
var sourceCodeProExtraLightItalic []byte
//go:embed fonts/Source_Code_Pro/SourceCodePro-Light.ttf
var sourceCodeProLight []byte
//go:embed fonts/Source_Code_Pro/SourceCodePro-LightItalic.ttf
var sourceCodeProLightItalic []byte
//go:embed fonts/Source_Code_Pro/SourceCodePro-Regular.ttf
var sourceCodeProRegular []byte
//go:embed fonts/Source_Code_Pro/SourceCodePro-Italic.ttf
var sourceCodeProItalic []byte
//go:embed fonts/Source_Code_Pro/SourceCodePro-Medium.ttf
var sourceCodeProMedium []byte
//go:embed fonts/Source_Code_Pro/SourceCodePro-MediumItalic.ttf
var sourceCodeProMediumItalic []byte
//go:embed fonts/Source_Code_Pro/SourceCodePro-SemiBold.ttf
var sourceCodeProSemiBold []byte
//go:embed fonts/Source_Code_Pro/SourceCodePro-SemiBoldItalic.ttf
var sourceCodeProSemiBoldItalic []byte
//go:embed fonts/Source_Code_Pro/SourceCodePro-Bold.ttf
var sourceCodeProBold []byte
//go:embed fonts/Source_Code_Pro/SourceCodePro-BoldItalic.ttf
var sourceCodeProBoldItalic []byte
//go:embed fonts/Source_Code_Pro/SourceCodePro-ExtraBold.ttf
var sourceCodeProExtraBold []byte
//go:embed fonts/Source_Code_Pro/SourceCodePro-ExtraBoldItalic.ttf
var sourceCodeProExtraBoldItalic []byte
//go:embed fonts/Source_Code_Pro/SourceCodePro-Black.ttf
var sourceCodeProBlack []byte
//go:embed fonts/Source_Code_Pro/SourceCodePro-BlackItalic.ttf
var sourceCodeProBlackItalic []byte
// FontWeight represents font weight
type FontWeight string
// Font weight constants
const (
// WeightThin represents thin font weight
WeightThin FontWeight = "thin"
WeightExtraLight FontWeight = "extralight"
WeightLight FontWeight = "light"
WeightRegular FontWeight = "regular"
WeightMedium FontWeight = "medium"
WeightSemiBold FontWeight = "semibold"
WeightBold FontWeight = "bold"
WeightExtraBold FontWeight = "extrabold"
WeightBlack FontWeight = "black"
)
// FontFamily represents a font family
type FontFamily string
// Font family constants
const (
// FamilyIBMPlexMono represents IBM Plex Mono font family
FamilyIBMPlexMono FontFamily = "ibmplexmono"
FamilySourceCodePro FontFamily = "sourcecodepro"
)
// LoadFont loads a font with the specified family, weight, and italic style
func LoadFont(family FontFamily, weight FontWeight, italic bool) (*truetype.Font, error) {
var fontData []byte
switch family {
case FamilyIBMPlexMono:
switch weight {
case WeightThin:
if italic {
fontData = ibmPlexMonoThinItalic
} else {
fontData = ibmPlexMonoThin
}
case WeightExtraLight:
if italic {
fontData = ibmPlexMonoExtraLightItalic
} else {
fontData = ibmPlexMonoExtraLight
}
case WeightLight:
if italic {
fontData = ibmPlexMonoLightItalic
} else {
fontData = ibmPlexMonoLight
}
case WeightRegular:
if italic {
fontData = ibmPlexMonoItalic
} else {
fontData = ibmPlexMonoRegular
}
case WeightMedium:
if italic {
fontData = ibmPlexMonoMediumItalic
} else {
fontData = ibmPlexMonoMedium
}
case WeightSemiBold:
if italic {
fontData = ibmPlexMonoSemiBoldItalic
} else {
fontData = ibmPlexMonoSemiBold
}
case WeightBold:
if italic {
fontData = ibmPlexMonoBoldItalic
} else {
fontData = ibmPlexMonoBold
}
default:
return nil, fmt.Errorf("unsupported weight %s for IBM Plex Mono", weight)
}
case FamilySourceCodePro:
switch weight {
case WeightExtraLight:
if italic {
fontData = sourceCodeProExtraLightItalic
} else {
fontData = sourceCodeProExtraLight
}
case WeightLight:
if italic {
fontData = sourceCodeProLightItalic
} else {
fontData = sourceCodeProLight
}
case WeightRegular:
if italic {
fontData = sourceCodeProItalic
} else {
fontData = sourceCodeProRegular
}
case WeightMedium:
if italic {
fontData = sourceCodeProMediumItalic
} else {
fontData = sourceCodeProMedium
}
case WeightSemiBold:
if italic {
fontData = sourceCodeProSemiBoldItalic
} else {
fontData = sourceCodeProSemiBold
}
case WeightBold:
if italic {
fontData = sourceCodeProBoldItalic
} else {
fontData = sourceCodeProBold
}
case WeightExtraBold:
if italic {
fontData = sourceCodeProExtraBoldItalic
} else {
fontData = sourceCodeProExtraBold
}
case WeightBlack:
if italic {
fontData = sourceCodeProBlackItalic
} else {
fontData = sourceCodeProBlack
}
default:
return nil, fmt.Errorf("unsupported weight %s for Source Code Pro", weight)
}
default:
return nil, fmt.Errorf("unsupported font family: %s", family)
}
if len(fontData) == 0 {
return nil, fmt.Errorf("font data not found for %s %s italic=%v", family, weight, italic)
}
font, err := truetype.Parse(fontData)
if err != nil {
return nil, fmt.Errorf("parsing font: %w", err)
}
return font, nil
}
// LoadIBMPlexMonoRegular loads the regular weight font
func LoadIBMPlexMonoRegular() (*truetype.Font, error) {
font, err := truetype.Parse(ibmPlexMonoRegular)
if err != nil {
return nil, fmt.Errorf("parsing regular font: %w", err)
}
return font, nil
// LoadIBMPlexMono loads the default IBM Plex Mono font (ExtraLight)
func LoadIBMPlexMono() (*truetype.Font, error) {
return LoadFont(FamilyIBMPlexMono, WeightExtraLight, false)
}
// LoadIBMPlexMonoBold loads the bold weight font
func LoadIBMPlexMonoBold() (*truetype.Font, error) {
font, err := truetype.Parse(ibmPlexMonoBold)
if err != nil {
return nil, fmt.Errorf("parsing bold font: %w", err)
}
return font, nil
// LoadIBMPlexMonoRegular loads IBM Plex Mono Regular font
func LoadIBMPlexMonoRegular() (*truetype.Font, error) {
return LoadFont(FamilyIBMPlexMono, WeightRegular, false)
}
// LoadIBMPlexMonoBold loads IBM Plex Mono Bold font
func LoadIBMPlexMonoBold() (*truetype.Font, error) {
return LoadFont(FamilyIBMPlexMono, WeightBold, false)
}

View File

@ -0,0 +1,93 @@
Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -0,0 +1,79 @@
Source Code Pro Variable Font
=============================
This download contains Source Code Pro as both variable fonts and static fonts.
Source Code Pro is a variable font with this axis:
wght
This means all the styles are contained in these files:
Source_Code_Pro/SourceCodePro-VariableFont_wght.ttf
Source_Code_Pro/SourceCodePro-Italic-VariableFont_wght.ttf
If your app fully supports variable fonts, you can now pick intermediate styles
that arent available as static fonts. Not all apps support variable fonts, and
in those cases you can use the static font files for Source Code Pro:
Source_Code_Pro/static/SourceCodePro-ExtraLight.ttf
Source_Code_Pro/static/SourceCodePro-Light.ttf
Source_Code_Pro/static/SourceCodePro-Regular.ttf
Source_Code_Pro/static/SourceCodePro-Medium.ttf
Source_Code_Pro/static/SourceCodePro-SemiBold.ttf
Source_Code_Pro/static/SourceCodePro-Bold.ttf
Source_Code_Pro/static/SourceCodePro-ExtraBold.ttf
Source_Code_Pro/static/SourceCodePro-Black.ttf
Source_Code_Pro/static/SourceCodePro-ExtraLightItalic.ttf
Source_Code_Pro/static/SourceCodePro-LightItalic.ttf
Source_Code_Pro/static/SourceCodePro-Italic.ttf
Source_Code_Pro/static/SourceCodePro-MediumItalic.ttf
Source_Code_Pro/static/SourceCodePro-SemiBoldItalic.ttf
Source_Code_Pro/static/SourceCodePro-BoldItalic.ttf
Source_Code_Pro/static/SourceCodePro-ExtraBoldItalic.ttf
Source_Code_Pro/static/SourceCodePro-BlackItalic.ttf
Get started
-----------
1. Install the font files you want to use
2. Use your app's font picker to view the font family and all the
available styles
Learn more about variable fonts
-------------------------------
https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts
https://variablefonts.typenetwork.com
https://medium.com/variable-fonts
In desktop apps
https://theblog.adobe.com/can-variable-fonts-illustrator-cc
https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts
Online
https://developers.google.com/fonts/docs/getting_started
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide
https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts
Installing fonts
MacOS: https://support.apple.com/en-us/HT201749
Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux
Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows
Android Apps
https://developers.google.com/fonts/docs/android
https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts
License
-------
Please read the full license text (OFL.txt) to understand the permissions,
restrictions and requirements for usage, redistribution, and modification.
You can use them in your products & projects print or digital,
commercial or otherwise.
This isn't legal advice, please consider consulting a lawyer and see the full
license for all details.

View File

@ -0,0 +1,94 @@
Copyright (C) 2020 Dimitar Toshkov Zhekov,
with Reserved Font Name "Terminus Font".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,280 @@
# Framebuffer Display API
A high-level Go package for easily creating text-based status displays on Linux framebuffers. Perfect for system monitors, embedded displays, IoT dashboards, and more.
## API Design Concepts
Below are four different API design approaches for creating framebuffer displays. Each example shows how you might implement a system status display.
### Concept 1: Builder Pattern
```go
package main
import (
"time"
fb "github.com/example/framebufferdisplay"
)
func main() {
// Create and configure display with fluent interface
display := fb.New().
AutoDetect(). // Find first available framebuffer
WithFont("IBM Plex Mono", 14). // Default font
WithUpdateInterval(time.Second). // Auto-refresh rate
Build()
defer display.Close()
// Define the layout
display.Layout(func(canvas *fb.Canvas) {
// Header section
canvas.Section("header", fb.TopCenter).
Font("IBM Plex Mono", 24).
Color(fb.White).
Text("System Status")
// System info section
canvas.Section("info", fb.TopLeft).
Margin(20).
Rows(
fb.Row().Label("Hostname:").Value(getHostname()),
fb.Row().Label("Uptime:").Value(getUptime()),
fb.Row().Label("Load:").Value(getLoad()).Color(fb.Red),
)
// CPU meters
canvas.Section("cpu", fb.CenterLeft).
Title("CPU Usage").
Meters(getCPUMeters()...)
// Memory bar
canvas.Section("memory", fb.BottomLeft).
Title("Memory").
ProgressBar(getMemoryPercent(), fb.Green)
})
// Start the display loop
display.Run()
}
```
### Concept 2: Declarative/React-like
```go
package main
import (
fb "github.com/example/framebufferdisplay"
)
type SystemStatus struct {
fb.Component
hostname string
uptime time.Duration
}
func (s *SystemStatus) Render() fb.Element {
return fb.Screen(
fb.Header(
fb.Text("System Status").
Font("IBM Plex Mono", 48).
Color(fb.RGB(100, 200, 255)),
),
fb.Grid(fb.GridOptions{Columns: 2, Gap: 20},
// Left column
fb.Column(
fb.Card(
fb.Title("System Info"),
fb.List(
fb.ListItem("Hostname", s.hostname),
fb.ListItem("Uptime", formatDuration(s.uptime)),
fb.ListItem("OS", getOS()),
),
),
fb.Card(
fb.Title("Network"),
fb.List(getNetworkInfo()...),
),
),
// Right column
fb.Column(
fb.Card(
fb.Title("CPU Usage"),
fb.BarChart(getCPUData(), fb.ChartOptions{
Height: 200,
Color: fb.Gradient(fb.Green, fb.Red),
}),
),
fb.Card(
fb.Title("Memory"),
fb.CircularProgress(getMemoryPercent(), fb.Blue),
fb.Text(getMemoryDetails()).Size(12),
),
),
),
)
}
func main() {
fb.Run(&SystemStatus{})
}
```
### Concept 3: Immediate Mode
```go
package main
import (
fb "github.com/example/framebufferdisplay"
)
func main() {
// Auto-detect and initialize
ctx := fb.Init()
defer ctx.Close()
// Main render loop
ctx.Loop(func(d *fb.Draw) {
// Clear with background
d.Clear(fb.Black)
// Draw header
d.SetFont("IBM Plex Mono Bold", 36)
d.SetColor(fb.White)
d.TextCenter(d.Width/2, 50, "System Monitor")
// System info box
d.SetFont("IBM Plex Mono", 14)
d.Box(20, 100, 400, 200, fb.Gray)
d.SetColor(fb.Green)
d.Text(30, 120, "Hostname: %s", getHostname())
d.Text(30, 140, "Uptime: %s", getUptime())
d.Text(30, 160, "Load: %.2f %.2f %.2f", getLoad())
// CPU visualization
cpus := getCPUPercents()
for i, cpu := range cpus {
y := 320 + i*30
d.Text(30, y, "CPU%d", i)
d.ProgressBar(80, y-10, 300, 20, cpu, fb.Heat(cpu))
}
// Memory meter
mem := getMemoryPercent()
d.SetFont("IBM Plex Mono", 18)
d.Text(30, 500, "Memory: %.1f%%", mem)
d.Gauge(30, 520, 350, 40, mem, fb.Blue)
// Update display
d.Present()
})
}
```
### Concept 4: Template/Widget-based
```go
package main
import (
fb "github.com/example/framebufferdisplay"
)
func main() {
// Create display with auto-detection
display := fb.NewDisplay()
// Create a dashboard with predefined widgets
dashboard := fb.Dashboard{
Title: "System Status",
Theme: fb.Themes.Dark,
Layout: fb.GridLayout(3, 3), // 3x3 grid
Widgets: []fb.Widget{
// Row 1
fb.BigNumber{
GridPos: fb.Pos(0, 0),
Label: "CPU Temp",
Value: getCPUTemp,
Unit: "°C",
Color: fb.TempColor, // Auto-colors based on value
},
fb.LineGraph{
GridPos: fb.Pos(1, 0).Span(2, 1), // Spans 2 columns
Title: "CPU History",
Duration: 5 * time.Minute,
Source: streamCPUData,
},
// Row 2
fb.InfoTable{
GridPos: fb.Pos(0, 1),
Rows: []fb.TableRow{
{"Host", getHostname},
{"Kernel", getKernel},
{"Uptime", getUptime},
},
},
fb.MultiMeter{
GridPos: fb.Pos(1, 1),
Title: "CPU Cores",
Meters: getCPUCoreMeters,
Compact: true,
},
fb.PieChart{
GridPos: fb.Pos(2, 1),
Title: "Disk Usage",
Data: getDiskUsage,
},
// Row 3
fb.MemoryWidget{
GridPos: fb.Pos(0, 2).Span(2, 1),
ShowDetails: true,
},
fb.NetworkTraffic{
GridPos: fb.Pos(2, 2),
Interface: "eth0",
},
},
// Optional: Add alerts
Alerts: []fb.Alert{
fb.Alert{
Condition: func() bool { return getCPUTemp() > 80 },
Message: "High CPU Temperature!",
Color: fb.Red,
},
},
}
// Run the dashboard
display.RunDashboard(dashboard)
}
```
## Key Features Across All Concepts
- **Auto-detection**: Automatically finds and configures the first available framebuffer
- **Resolution independence**: Layouts adapt to the detected resolution
- **Font management**: Easy font loading and sizing
- **Color utilities**: Named colors, RGB, gradients, and conditional coloring
- **Common widgets**: Progress bars, meters, graphs, tables, etc.
- **Refresh control**: Configurable update intervals or manual control
- **Error handling**: Graceful fallbacks for missing fonts, permissions, etc.
## Design Considerations
Each approach offers different benefits:
1. **Builder Pattern**: Familiar to Go developers, good for static layouts
2. **Declarative**: Clean separation of data and presentation, easy to test
3. **Immediate Mode**: Simple and direct, good for dynamic content
4. **Widget-based**: Highest level abstraction, fastest to build common dashboards
The final API could combine elements from multiple approaches, such as using the widget system from Concept 4 with the immediate mode drawing primitives from Concept 3 for custom widgets.

View File

@ -1,3 +1,4 @@
// Package hdmistat provides the CLI commands for the hdmistat application
package hdmistat
import (

View File

@ -2,9 +2,11 @@ package hdmistat
import (
"context"
"fmt"
"log/slog"
"os"
"os/signal"
"runtime"
"syscall"
"git.eeqj.de/sneak/hdmistat/internal/app"
@ -27,7 +29,7 @@ func (c *CLI) newDaemonCmd() *cobra.Command {
Use: "daemon",
Short: "Run hdmistat as a daemon",
Long: `Run hdmistat as a daemon that displays system statistics on the framebuffer.`,
Run: func(cmd *cobra.Command, args []string) {
Run: func(cmd *cobra.Command, _ []string) {
c.runDaemon(cmd, framebufferDevice, configFile)
},
}
@ -38,7 +40,7 @@ func (c *CLI) newDaemonCmd() *cobra.Command {
return cmd
}
func (c *CLI) runDaemon(cmd *cobra.Command, framebufferDevice, configFile string) {
func (c *CLI) runDaemon(cmd *cobra.Command, framebufferDevice, _ string) {
// Set up signal handling
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@ -52,6 +54,21 @@ func (c *CLI) runDaemon(cmd *cobra.Command, framebufferDevice, configFile string
cancel()
}()
// Set up debug signal handler for SIGUSR1
debugChan := make(chan os.Signal, 1)
signal.Notify(debugChan, syscall.SIGUSR1)
go func() {
for range debugChan {
c.log.Info("received SIGUSR1, dumping goroutines")
// Dump all goroutine stack traces to stderr
const megabyte = 1 << 20
buf := make([]byte, megabyte) // 1MB buffer
stackSize := runtime.Stack(buf, true)
fmt.Fprintf(os.Stderr, "\n=== GOROUTINE DUMP ===\n%s\n=== END GOROUTINE DUMP ===\n", buf[:stackSize])
}
}()
// Load configuration
cfg, err := config.Load(ctx)
if err != nil {
@ -64,11 +81,13 @@ func (c *CLI) runDaemon(cmd *cobra.Command, framebufferDevice, configFile string
cfg.FramebufferDevice = framebufferDevice
}
// Update logger level
// Update logger level - use stderr for serial console visibility
c.log = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: cfg.GetLogLevel(),
}))
c.log.Info("hdmistat daemon starting", "log_level", cfg.LogLevel, "framebuffer", cfg.FramebufferDevice)
// Create fx application
fxApp := fx.New(
fx.Provide(
@ -87,14 +106,30 @@ func (c *CLI) runDaemon(cmd *cobra.Command, framebufferDevice, configFile string
},
// Provide collector
func(logger *slog.Logger) statcollector.Collector {
return statcollector.NewSystemCollector(logger)
func(lc fx.Lifecycle, logger *slog.Logger) statcollector.Collector {
collector := statcollector.NewSystemCollector(logger)
lc.Append(fx.Hook{
OnStop: func(_ context.Context) error {
collector.Stop()
return nil
},
})
return collector
},
// Provide renderer
func(font *truetype.Font, logger *slog.Logger, cfg *config.Config) *renderer.Renderer {
func(font *truetype.Font, logger *slog.Logger, disp display.Display) *renderer.Renderer {
r := renderer.NewRenderer(font, logger)
r.SetResolution(cfg.Width, cfg.Height)
// Get actual framebuffer resolution
if fbDisp, ok := disp.(*display.FramebufferDisplay); ok && fbDisp != nil {
width := int(fbDisp.GetWidth())
height := int(fbDisp.GetHeight())
logger.Info("using framebuffer resolution", "width", width, "height", height)
r.SetResolution(width, height)
}
return r
},
@ -102,7 +137,7 @@ func (c *CLI) runDaemon(cmd *cobra.Command, framebufferDevice, configFile string
app.NewApp,
),
fx.Invoke(func(a *app.App) {
fx.Invoke(func(_ *app.App) {
// App will be started by fx lifecycle
}),
)

View File

@ -9,6 +9,10 @@ import (
"github.com/spf13/cobra"
)
const (
percentMultiplier = 100.0
)
// newInfoCmd creates the info command
func (c *CLI) newInfoCmd() *cobra.Command {
return &cobra.Command{
@ -19,7 +23,7 @@ func (c *CLI) newInfoCmd() *cobra.Command {
}
}
func (c *CLI) runInfo(cmd *cobra.Command, args []string) {
func (c *CLI) runInfo(_ *cobra.Command, _ []string) {
collector := statcollector.NewSystemCollector(c.log)
c.log.Info("collecting system information")
@ -40,7 +44,7 @@ func (c *CLI) runInfo(cmd *cobra.Command, args []string) {
fmt.Printf("Total: %s\n", layout.FormatBytes(info.MemoryTotal))
fmt.Printf("Used: %s (%.1f%%)\n",
layout.FormatBytes(info.MemoryUsed),
float64(info.MemoryUsed)/float64(info.MemoryTotal)*100)
float64(info.MemoryUsed)/float64(info.MemoryTotal)*percentMultiplier)
fmt.Printf("Free: %s\n", layout.FormatBytes(info.MemoryFree))
fmt.Println()

View File

@ -22,21 +22,24 @@ func (c *CLI) newInstallCmd() *cobra.Command {
const systemdUnit = `[Unit]
Description=HDMI Statistics Display Daemon
After=multi-user.target
Conflicts=getty@tty1.service
[Service]
Type=simple
ExecStart=%s daemon
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
StandardOutput=journal+console
StandardError=journal+console
SyslogIdentifier=hdmistat
Environment="HDMISTAT_LOG_LEVEL=debug"
Environment="GOTRACEBACK=all"
[Install]
WantedBy=multi-user.target
`
func (c *CLI) runInstall(cmd *cobra.Command, args []string) {
func (c *CLI) runInstall(_ *cobra.Command, _ []string) {
// Check if running as root
if os.Geteuid() != 0 {
c.log.Error("install command must be run as root")
@ -63,7 +66,8 @@ func (c *CLI) runInstall(cmd *cobra.Command, args []string) {
unitContent := fmt.Sprintf(systemdUnit, hdmistatPath)
unitPath := "/etc/systemd/system/hdmistat.service"
err = os.WriteFile(unitPath, []byte(unitContent), 0644)
const fileMode = 0600
err = os.WriteFile(unitPath, []byte(unitContent), fileMode)
if err != nil {
c.log.Error("writing systemd unit file", "error", err)
os.Exit(1)
@ -73,7 +77,8 @@ func (c *CLI) runInstall(cmd *cobra.Command, args []string) {
// Create config directory
configDir := "/etc/hdmistat"
err = os.MkdirAll(configDir, 0755)
const dirMode = 0750
err = os.MkdirAll(configDir, dirMode)
if err != nil {
c.log.Error("creating config directory", "error", err)
os.Exit(1)
@ -84,20 +89,32 @@ func (c *CLI) runInstall(cmd *cobra.Command, args []string) {
if _, err := os.Stat(configPath); os.IsNotExist(err) {
defaultConfig := `# hdmistat configuration file
# This file is optional - hdmistat will use sensible defaults if not present
# Uncomment and modify any settings you want to override
framebuffer_device: /dev/fb0
rotation_interval: 10s
update_interval: 1s
log_level: info
width: 1920
height: 1080
# Framebuffer device to use for display
#framebuffer_device: /dev/fb0
screens:
- overview
- top_cpu
- top_memory
# How often to rotate between screens
#rotation_interval: 10s
# How often to update the current screen
#update_interval: 1s
# Log level: debug, info, warn, error
#log_level: info
# Display resolution (auto-detected from framebuffer if not set)
#width: 1920
#height: 1080
# Screens to display in rotation order
#screens:
# - overview
# - top_cpu
# - top_memory
`
err = os.WriteFile(configPath, []byte(defaultConfig), 0644)
const configFileMode = 0600
err = os.WriteFile(configPath, []byte(defaultConfig), configFileMode)
if err != nil {
c.log.Error("writing default config", "error", err)
os.Exit(1)

View File

@ -17,14 +17,15 @@ func (c *CLI) newStatusCmd() *cobra.Command {
}
}
func (c *CLI) runStatus(cmd *cobra.Command, args []string) {
func (c *CLI) runStatus(_ *cobra.Command, _ []string) {
// Check systemd service status
out, err := exec.Command("systemctl", "status", "hdmistat.service", "--no-pager").Output()
if err != nil {
// Service might not be installed
if exitErr, ok := err.(*exec.ExitError); ok {
fmt.Printf("hdmistat service status:\n%s", exitErr.Stderr)
if exitErr.ExitCode() == 4 {
const systemdServiceNotFoundCode = 4
if exitErr.ExitCode() == systemdServiceNotFoundCode {
fmt.Println("\nhdmistat service is not installed. Run 'sudo hdmistat install' to install it.")
}
} else {

442
internal/layout/draw.go Normal file
View File

@ -0,0 +1,442 @@
// Package layout provides a simple API for creating text-based layouts
// that can be rendered to fbdraw grids for display in a carousel.
package layout
import (
"fmt"
"image/color"
"git.eeqj.de/sneak/hdmistat/internal/fbdraw"
"git.eeqj.de/sneak/hdmistat/internal/font"
)
// Font represents a bundled monospace font.
type Font int
const (
// PlexMono is IBM Plex Mono, a modern monospace font with good readability.
PlexMono Font = iota
// Terminus is a bitmap font optimized for long-term reading on terminals.
Terminus
// SourceCodePro is Adobe's Source Code Pro, designed for coding environments.
SourceCodePro
)
// Color returns a standard color by name
func Color(name string) color.Color {
switch name {
case "black":
return color.RGBA{0, 0, 0, 255}
case "white":
return color.RGBA{255, 255, 255, 255}
case "red":
return color.RGBA{255, 0, 0, 255}
case "green":
return color.RGBA{0, 255, 0, 255}
case "blue":
return color.RGBA{0, 0, 255, 255}
case "yellow":
return color.RGBA{255, 255, 0, 255}
case "cyan":
return color.RGBA{0, 255, 255, 255}
case "magenta":
return color.RGBA{255, 0, 255, 255}
case "orange":
return color.RGBA{255, 165, 0, 255}
case "purple":
return color.RGBA{128, 0, 128, 255}
case "gray10":
return color.RGBA{26, 26, 26, 255}
case "gray20":
return color.RGBA{51, 51, 51, 255}
case "gray30":
return color.RGBA{77, 77, 77, 255}
case "gray40":
return color.RGBA{102, 102, 102, 255}
case "gray50":
return color.RGBA{128, 128, 128, 255}
case "gray60":
return color.RGBA{153, 153, 153, 255}
case "gray70":
return color.RGBA{179, 179, 179, 255}
case "gray80":
return color.RGBA{204, 204, 204, 255}
case "gray90":
return color.RGBA{230, 230, 230, 255}
default:
return color.RGBA{255, 255, 255, 255} // Default to white
}
}
// Draw provides the drawing context for creating a layout.
// It maintains state for font, size, colors, and text styling.
type Draw struct {
// Drawing state
font Font
fontSize int
bold bool
italic bool
fgColor color.Color
bgColor color.Color
// Grid to render to
grid *fbdraw.CharGrid
// Cached dimensions
Width int
Height int
}
// NewDraw creates a new drawing context with the specified dimensions
func NewDraw(width, height int) *Draw {
grid := fbdraw.NewCharGrid(width, height)
return &Draw{
grid: grid,
Width: width,
Height: height,
fontSize: 14,
fgColor: color.RGBA{255, 255, 255, 255},
bgColor: color.RGBA{0, 0, 0, 255},
}
}
// Render returns the current grid for rendering by the carousel
func (d *Draw) Render() *fbdraw.CharGrid {
return d.grid
}
// Clear fills the entire display with black.
func (d *Draw) Clear() {
d.grid.Clear(color.RGBA{0, 0, 0, 255})
}
// ClearColor fills the entire display with the specified color.
func (d *Draw) ClearColor(c color.Color) {
// Fill all cells with spaces and the background color
for y := 0; y < d.Height; y++ {
for x := 0; x < d.Width; x++ {
weight := font.WeightRegular
if d.bold {
weight = font.WeightBold
}
d.grid.SetCell(x, y, ' ', d.fgColor, c, weight, d.italic)
}
}
}
// Font sets the current font for text operations.
func (d *Draw) Font(f Font) *Draw {
d.font = f
return d
}
// Size sets the current font size in points.
func (d *Draw) Size(points int) *Draw {
d.fontSize = points
d.grid.FontSize = float64(points)
return d
}
// Bold enables bold text rendering.
func (d *Draw) Bold() *Draw {
d.bold = true
return d
}
// Plain disables bold and italic text rendering.
func (d *Draw) Plain() *Draw {
d.bold = false
d.italic = false
return d
}
// Italic enables italic text rendering.
func (d *Draw) Italic() *Draw {
d.italic = true
return d
}
// Color sets the foreground color for text operations.
func (d *Draw) Color(c color.Color) *Draw {
d.fgColor = c
return d
}
// Background sets the background color for text operations.
func (d *Draw) Background(c color.Color) *Draw {
d.bgColor = c
return d
}
// Text draws text at the specified character coordinates.
func (d *Draw) Text(x, y int, format string, args ...interface{}) {
text := fmt.Sprintf(format, args...)
writer := fbdraw.NewGridWriter(d.grid)
writer.MoveAbs(x, y)
writer.SetColor(d.fgColor)
writer.SetBackground(d.bgColor)
if d.bold {
writer.SetWeight(font.WeightBold)
} else {
writer.SetWeight(font.WeightRegular)
}
writer.SetItalic(d.italic)
writer.Write(text)
}
// TextCenter draws centered text at the specified y coordinate.
func (d *Draw) TextCenter(x, y int, format string, args ...interface{}) {
text := fmt.Sprintf(format, args...)
// Calculate starting position for centered text
startX := x + (d.Width-len(text))/2
d.Text(startX, y, text)
}
// Grid creates a text grid region for simplified text layout.
// The grid uses the current font settings from the Draw context.
func (d *Draw) Grid(x, y, cols, rows int) *Grid {
return &Grid{
draw: d,
x: x,
y: y,
cols: cols,
rows: rows,
}
}
// Grid represents a rectangular text grid for structured text layout.
// All positions are in character cells, not pixels.
type Grid struct {
draw *Draw
x, y int
cols, rows int
// Grid-specific state that can override draw state
fgColor color.Color
bgColor color.Color
borderColor color.Color
hasBorder bool
}
// Write places text at the specified row and column within the grid.
// Text that exceeds the grid bounds is clipped.
func (g *Grid) Write(col, row int, format string, args ...interface{}) {
if row < 0 || row >= g.rows || col < 0 || col >= g.cols {
return
}
text := fmt.Sprintf(format, args...)
// Calculate absolute position
absX := g.x + col
absY := g.y + row
// Write text with clipping
writer := fbdraw.NewGridWriter(g.draw.grid)
writer.MoveAbs(absX, absY)
if g.fgColor != nil {
writer.SetColor(g.fgColor)
} else {
writer.SetColor(g.draw.fgColor)
}
if g.bgColor != nil {
writer.SetBackground(g.bgColor)
} else {
writer.SetBackground(g.draw.bgColor)
}
// Clip text to grid bounds
maxLen := g.cols - col
if len(text) > maxLen {
text = text[:maxLen]
}
writer.Write(text)
}
// WriteCenter centers text within the specified row.
func (g *Grid) WriteCenter(row int, format string, args ...interface{}) {
if row < 0 || row >= g.rows {
return
}
text := fmt.Sprintf(format, args...)
col := (g.cols - len(text)) / 2
if col < 0 {
col = 0
}
g.Write(col, row, text)
}
// Color sets the foreground color for subsequent Write operations.
func (g *Grid) Color(c color.Color) *Grid {
g.fgColor = c
return g
}
// Background sets the background color for the entire grid.
func (g *Grid) Background(c color.Color) *Grid {
g.bgColor = c
// Fill the grid area with the background color
for row := 0; row < g.rows; row++ {
for col := 0; col < g.cols; col++ {
weight := font.WeightRegular
if g.draw.bold {
weight = font.WeightBold
}
g.draw.grid.SetCell(g.x+col, g.y+row, ' ', g.draw.fgColor, c, weight, g.draw.italic)
}
}
return g
}
// Border draws a border around the grid in the specified color.
func (g *Grid) Border(c color.Color) *Grid {
g.borderColor = c
g.hasBorder = true
// Draw border using box drawing characters
writer := fbdraw.NewGridWriter(g.draw.grid)
writer.SetColor(c)
// Top border
writer.MoveAbs(g.x-1, g.y-1)
writer.Write("┌")
for i := 0; i < g.cols; i++ {
writer.Write("─")
}
writer.Write("┐")
// Side borders
for row := 0; row < g.rows; row++ {
writer.MoveAbs(g.x-1, g.y+row)
writer.Write("│")
writer.MoveAbs(g.x+g.cols, g.y+row)
writer.Write("│")
}
// Bottom border
writer.MoveAbs(g.x-1, g.y+g.rows)
writer.Write("└")
for i := 0; i < g.cols; i++ {
writer.Write("─")
}
writer.Write("┘")
return g
}
// RowBackground sets the background color for a specific row.
func (g *Grid) RowBackground(row int, c color.Color) {
if row < 0 || row >= g.rows {
return
}
for col := 0; col < g.cols; col++ {
g.draw.grid.SetCell(g.x+col, g.y+row, ' ', g.draw.fgColor, c, font.WeightRegular, false)
}
}
// RowColor sets the text color for an entire row.
func (g *Grid) RowColor(row int, c color.Color) {
if row < 0 || row >= g.rows {
return
}
// This would need to track row colors for subsequent writes
// For now, we'll just update existing text in the row
for col := 0; col < g.cols; col++ {
// Get current cell to preserve other attributes
if g.y+row < g.draw.grid.Height && g.x+col < g.draw.grid.Width {
cell := &g.draw.grid.Cells[g.y+row][g.x+col]
cell.Foreground = c
}
}
}
// Bar draws a horizontal progress bar within the grid cell.
func (g *Grid) Bar(col, row, width int, percent float64, c color.Color) {
if row < 0 || row >= g.rows || col < 0 || col >= g.cols {
return
}
// Ensure width doesn't exceed grid bounds
maxWidth := g.cols - col
if width > maxWidth {
width = maxWidth
}
writer := fbdraw.NewGridWriter(g.draw.grid)
writer.MoveAbs(g.x+col, g.y+row)
writer.SetColor(c)
writer.DrawMeter(percent, width)
}
// Bold enables bold text for subsequent Write operations.
func (g *Grid) Bold() *Grid {
// TODO: Track bold state for grid
return g
}
// Plain disables text styling for subsequent Write operations.
func (g *Grid) Plain() *Grid {
// TODO: Clear text styling for grid
return g
}
// Meter creates a text-based progress meter using Unicode block characters.
// The width parameter specifies the number of characters.
// Returns a string like "█████████░░░░░░" for 60% with width 15.
func Meter(percent float64, width int) string {
if percent < 0 {
percent = 0
}
if percent > 100 {
percent = 100
}
filled := int(percent / 100.0 * float64(width))
result := ""
for i := 0; i < width; i++ {
if i < filled {
result += "█"
} else {
result += "░"
}
}
return result
}
// Bytes formats a byte count as a human-readable string.
// For example: 1234567890 becomes "1.2 GB".
func Bytes(bytes uint64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := uint64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
// Heat returns a color between blue and red based on the value.
// 0.0 returns blue, 1.0 returns red, with gradients in between.
func Heat(value float64) color.Color {
if value < 0 {
value = 0
}
if value > 1 {
value = 1
}
// Simple linear interpolation between blue and red
r := uint8(255 * value)
g := uint8(0)
b := uint8(255 * (1 - value))
return color.RGBA{r, g, b, 255}
}
// RGB creates a color from red, green, and blue values (0-255).
func RGB(r, g, b uint8) color.Color {
return color.RGBA{r, g, b, 255}
}

View File

@ -0,0 +1,64 @@
package layout_test
import (
"testing"
"time"
"git.eeqj.de/sneak/hdmistat/internal/fbdraw"
"git.eeqj.de/sneak/hdmistat/internal/layout"
)
// ExampleScreen shows how to create a screen that implements FrameGenerator
type ExampleScreen struct {
name string
fps float64
}
func (s *ExampleScreen) GenerateFrame(grid *fbdraw.CharGrid) error {
// Create a draw context with the grid dimensions
draw := layout.NewDraw(grid.Width, grid.Height)
// Clear the screen
draw.Clear()
// Draw a title
draw.Color(layout.Color("cyan")).Size(16).Bold()
draw.TextCenter(0, 2, "Example Screen: %s", s.name)
// Create a grid for structured layout
contentGrid := draw.Grid(5, 5, 70, 20)
contentGrid.Border(layout.Color("gray50"))
// Add some content
contentGrid.Color(layout.Color("white")).WriteCenter(1, "Current Time: %s", time.Now().Format("15:04:05"))
// Draw a progress bar
contentGrid.Color(layout.Color("green")).Bar(10, 5, 50, 75.0, layout.Color("green"))
// Add system stats
contentGrid.Color(layout.Color("yellow")).Write(2, 8, "CPU: %.1f%%", 42.5)
contentGrid.Color(layout.Color("orange")).Write(2, 9, "Memory: %s / %s", layout.Bytes(4*1024*1024*1024), layout.Bytes(16*1024*1024*1024))
// Return the rendered grid
*grid = *draw.Render()
return nil
}
func (s *ExampleScreen) FramesPerSecond() float64 {
return s.fps
}
func TestExampleUsage(t *testing.T) {
// Create carousel with terminal display for testing
display := fbdraw.NewTerminalDisplay(80, 25)
carousel := fbdraw.NewCarousel(display, 5*time.Second)
// Add screens
carousel.AddScreen(&ExampleScreen{name: "Dashboard", fps: 1.0})
carousel.AddScreen(&ExampleScreen{name: "System Monitor", fps: 2.0})
carousel.AddScreen(&ExampleScreen{name: "Network Stats", fps: 0.5})
// In a real application, you would run this in a goroutine
// ctx := context.Background()
// go carousel.Run(ctx)
}

View File

@ -1,3 +1,4 @@
// Package layout provides canvas and drawing utilities for hdmistat
package layout
import (
@ -12,6 +13,21 @@ import (
"golang.org/x/image/font"
)
const (
// Display constants
defaultDPI = 72
percentDivisor = 100.0
halfDivisor = 2
// Time constants
secondsPerDay = 86400
secondsPerHour = 3600
secondsPerMinute = 60
// Byte formatting constants
byteUnit = 1024
)
// Canvas provides a simple API for rendering text and graphics
type Canvas struct {
img *image.RGBA
@ -24,14 +40,18 @@ type TextStyle struct {
Size float64
Color color.Color
Alignment Alignment
Bold bool
}
// Alignment for text rendering
type Alignment int
const (
// AlignLeft aligns text to the left
AlignLeft Alignment = iota
// AlignCenter centers text
AlignCenter
// AlignRight aligns text to the right
AlignRight
)
@ -66,7 +86,7 @@ func (c *Canvas) DrawText(text string, pos Point, style TextStyle) error {
}
ctx := freetype.NewContext()
ctx.SetDPI(72)
ctx.SetDPI(defaultDPI)
ctx.SetFont(c.font)
ctx.SetFontSize(style.Size)
ctx.SetClip(c.img.Bounds())
@ -76,7 +96,7 @@ func (c *Canvas) DrawText(text string, pos Point, style TextStyle) error {
// Calculate text bounds for alignment
opts := truetype.Options{
Size: style.Size,
DPI: 72,
DPI: defaultDPI,
}
face := truetype.NewFace(c.font, &opts)
bounds, _ := font.BoundString(face, text)
@ -85,7 +105,7 @@ func (c *Canvas) DrawText(text string, pos Point, style TextStyle) error {
x := pos.X
switch style.Alignment {
case AlignCenter:
x = pos.X - width.Round()/2
x = pos.X - width.Round()/halfDivisor
case AlignRight:
x = pos.X - width.Round()
}
@ -131,7 +151,7 @@ func (c *Canvas) DrawProgress(x, y, width, height int, percent float64, fg, bg c
c.DrawBox(x, y, width, height, bg)
// Foreground
fillWidth := int(float64(width) * percent / 100.0)
fillWidth := int(float64(width) * percent / percentDivisor)
if fillWidth > 0 {
c.DrawBox(x, y, fillWidth, height, fg)
}
@ -163,13 +183,12 @@ func (c *Canvas) Size() (width, height int) {
// FormatBytes formats byte counts for display
func FormatBytes(bytes uint64) string {
const unit = 1024
if bytes < unit {
if bytes < byteUnit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := uint64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
div, exp := uint64(byteUnit), 0
for n := bytes / byteUnit; n >= byteUnit; n /= byteUnit {
div *= byteUnit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
@ -178,9 +197,9 @@ func FormatBytes(bytes uint64) string {
// FormatDuration formats time durations for display
func FormatDuration(d float64) string {
seconds := int(d)
days := seconds / 86400
hours := (seconds % 86400) / 3600
minutes := (seconds % 3600) / 60
days := seconds / secondsPerDay
hours := (seconds % secondsPerDay) / secondsPerHour
minutes := (seconds % secondsPerHour) / secondsPerMinute
if days > 0 {
return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)

View File

@ -0,0 +1,95 @@
package layout
import (
"fmt"
"image/color"
)
const (
// Progress bar label positioning
labelTopOffset = 5
labelBottomOffset = 15
labelSizeReduction = 2
percentMultiplier = 100
percentTextOffset = 5
)
// ProgressBar draws a labeled progress bar
type ProgressBar struct {
X, Y int
Width, Height int
Value float64 // 0.0 to 1.0
Label string
LeftLabel string
RightLabel string
BarColor color.Color
BGColor color.Color
TextColor color.Color
LabelSize float64
}
// Draw renders the progress bar on the canvas
func (p *ProgressBar) Draw(canvas *Canvas) {
// Default colors
if p.BarColor == nil {
p.BarColor = color.RGBA{100, 200, 255, 255}
}
if p.BGColor == nil {
p.BGColor = color.RGBA{50, 50, 50, 255}
}
if p.TextColor == nil {
p.TextColor = color.RGBA{255, 255, 255, 255}
}
if p.LabelSize == 0 {
p.LabelSize = 14
}
// Ensure value is between 0 and 1
value := p.Value
if value < 0 {
value = 0
}
if value > 1 {
value = 1
}
// Draw background
canvas.DrawBox(p.X, p.Y, p.Width, p.Height, p.BGColor)
// Draw filled portion
filledWidth := int(float64(p.Width) * value)
if filledWidth > 0 {
canvas.DrawBox(p.X, p.Y, filledWidth, p.Height, p.BarColor)
}
// Draw label above bar if provided
if p.Label != "" {
labelStyle := TextStyle{Size: p.LabelSize, Color: p.TextColor}
_ = canvas.DrawText(p.Label, Point{X: p.X, Y: p.Y - labelTopOffset}, labelStyle)
}
// Draw left label
if p.LeftLabel != "" {
labelStyle := TextStyle{Size: p.LabelSize - labelSizeReduction, Color: p.TextColor}
_ = canvas.DrawText(p.LeftLabel, Point{X: p.X, Y: p.Y + p.Height + labelBottomOffset}, labelStyle)
}
// Draw right label
if p.RightLabel != "" {
labelStyle := TextStyle{Size: p.LabelSize - labelSizeReduction, Color: p.TextColor, Alignment: AlignRight}
_ = canvas.DrawText(p.RightLabel, Point{X: p.X + p.Width, Y: p.Y + p.Height + labelBottomOffset}, labelStyle)
}
// Draw percentage in center of bar
percentText := fmt.Sprintf("%.1f%%", value*percentMultiplier)
centerStyle := TextStyle{
Size: p.LabelSize,
Color: color.RGBA{255, 255, 255, 255},
Alignment: AlignCenter,
}
pt := Point{
X: p.X + p.Width/halfDivisor,
Y: p.Y + p.Height/halfDivisor + percentTextOffset,
}
_ = canvas.DrawText(percentText, pt, centerStyle)
}

259
internal/netmon/netmon.go Normal file
View File

@ -0,0 +1,259 @@
// Package netmon provides network interface monitoring with historical data
package netmon
import (
"context"
"log/slog"
"strings"
"sync"
"time"
"github.com/dustin/go-humanize"
psnet "github.com/shirou/gopsutil/v3/net"
)
const (
ringBufferSize = 60 // Keep 60 seconds of history
sampleInterval = time.Second
rateWindowSeconds = 5 // Window size for rate calculation
bitsPerByte = 8
)
// Sample represents a single point-in-time network sample
type Sample struct {
BytesSent uint64
BytesRecv uint64
Timestamp time.Time
}
// InterfaceStats holds the ring buffer for a single interface
type InterfaceStats struct {
samples [ringBufferSize]Sample
head int // Points to the oldest sample
count int // Number of valid samples
lastSample Sample
}
// Stats represents current stats for an interface
type Stats struct {
Name string
BytesSent uint64
BytesRecv uint64
BitsSentRate uint64 // bits per second
BitsRecvRate uint64 // bits per second
}
// FormatSentRate returns the send rate as a human-readable string
func (s *Stats) FormatSentRate() string {
return humanize.SI(float64(s.BitsSentRate), "bit/s")
}
// FormatRecvRate returns the receive rate as a human-readable string
func (s *Stats) FormatRecvRate() string {
return humanize.SI(float64(s.BitsRecvRate), "bit/s")
}
// Monitor tracks network statistics for all interfaces
type Monitor struct {
mu sync.RWMutex
interfaces map[string]*InterfaceStats
logger *slog.Logger
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
}
// New creates a new network monitor
func New(logger *slog.Logger) *Monitor {
ctx, cancel := context.WithCancel(context.Background())
m := &Monitor{
interfaces: make(map[string]*InterfaceStats),
logger: logger,
ctx: ctx,
cancel: cancel,
}
return m
}
// Start begins monitoring network interfaces
func (m *Monitor) Start() {
m.wg.Add(1)
go m.monitorLoop()
}
// Stop stops the monitor
func (m *Monitor) Stop() {
m.cancel()
m.wg.Wait()
}
// GetStats returns current stats for all interfaces
func (m *Monitor) GetStats() []Stats {
m.mu.RLock()
defer m.mu.RUnlock()
var stats []Stats
for name, ifaceStats := range m.interfaces {
// Skip interfaces with no samples
if ifaceStats.count == 0 {
continue
}
// Calculate rates over available samples (up to 5 seconds)
rate := m.calculateRate(ifaceStats, rateWindowSeconds)
stats = append(stats, Stats{
Name: name,
BytesSent: ifaceStats.lastSample.BytesSent,
BytesRecv: ifaceStats.lastSample.BytesRecv,
BitsSentRate: uint64(rate.sentRate * bitsPerByte), // Convert to bits/sec
BitsRecvRate: uint64(rate.recvRate * bitsPerByte), // Convert to bits/sec
})
}
return stats
}
// GetStatsForInterface returns stats for a specific interface
func (m *Monitor) GetStatsForInterface(name string) (*Stats, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
ifaceStats, ok := m.interfaces[name]
if !ok || ifaceStats.count == 0 {
return nil, false
}
rate := m.calculateRate(ifaceStats, 5)
return &Stats{
Name: name,
BytesSent: ifaceStats.lastSample.BytesSent,
BytesRecv: ifaceStats.lastSample.BytesRecv,
BitsSentRate: uint64(rate.sentRate * 8), // Convert to bits/sec
BitsRecvRate: uint64(rate.recvRate * 8), // Convert to bits/sec
}, true
}
type rateInfo struct {
sentRate float64 // bytes per second
recvRate float64 // bytes per second
}
// calculateRate calculates the average rate over the last n seconds
func (m *Monitor) calculateRate(ifaceStats *InterfaceStats, seconds int) rateInfo {
if ifaceStats.count <= 1 {
return rateInfo{}
}
// Determine how many samples to use (up to requested seconds)
samplesToUse := seconds
if samplesToUse > ifaceStats.count-1 {
samplesToUse = ifaceStats.count - 1
}
if samplesToUse > ringBufferSize-1 {
samplesToUse = ringBufferSize - 1
}
// Get the most recent sample
newestIdx := (ifaceStats.head + ifaceStats.count - 1) % ringBufferSize
newest := ifaceStats.samples[newestIdx]
// Get the sample from n seconds ago
oldestIdx := (ifaceStats.head + ifaceStats.count - 1 - samplesToUse) % ringBufferSize
oldest := ifaceStats.samples[oldestIdx]
// Calculate time difference
timeDiff := newest.Timestamp.Sub(oldest.Timestamp).Seconds()
if timeDiff <= 0 {
return rateInfo{}
}
// Calculate rates
bytesSentDiff := float64(newest.BytesSent - oldest.BytesSent)
bytesRecvDiff := float64(newest.BytesRecv - oldest.BytesRecv)
return rateInfo{
sentRate: bytesSentDiff / timeDiff,
recvRate: bytesRecvDiff / timeDiff,
}
}
// monitorLoop continuously samples network statistics
func (m *Monitor) monitorLoop() {
defer m.wg.Done()
ticker := time.NewTicker(sampleInterval)
defer ticker.Stop()
// Take initial sample
m.takeSample()
for {
select {
case <-m.ctx.Done():
return
case <-ticker.C:
m.takeSample()
}
}
}
// takeSample captures current network statistics
func (m *Monitor) takeSample() {
counters, err := psnet.IOCounters(true)
if err != nil {
m.logger.Warn("failed to get network counters", "error", err)
return
}
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now()
currentInterfaces := make(map[string]bool)
for _, counter := range counters {
// Skip loopback and docker interfaces
if counter.Name == "lo" || strings.HasPrefix(counter.Name, "docker") {
continue
}
currentInterfaces[counter.Name] = true
// Get or create interface stats
ifaceStats, exists := m.interfaces[counter.Name]
if !exists {
ifaceStats = &InterfaceStats{}
m.interfaces[counter.Name] = ifaceStats
}
// Create new sample
sample := Sample{
BytesSent: counter.BytesSent,
BytesRecv: counter.BytesRecv,
Timestamp: now,
}
// Add to ring buffer
if ifaceStats.count < ringBufferSize {
// Buffer not full yet
idx := ifaceStats.count
ifaceStats.samples[idx] = sample
ifaceStats.count++
} else {
// Buffer is full, overwrite oldest
ifaceStats.samples[ifaceStats.head] = sample
ifaceStats.head = (ifaceStats.head + 1) % ringBufferSize
}
ifaceStats.lastSample = sample
}
// Remove interfaces that no longer exist
for name := range m.interfaces {
if !currentInterfaces[name] {
delete(m.interfaces, name)
}
}
}

View File

@ -1,156 +1,222 @@
// Package renderer provides screen rendering implementations for hdmistat
package renderer
import (
"fmt"
"image/color"
"strings"
"git.eeqj.de/sneak/hdmistat/internal/layout"
"git.eeqj.de/sneak/hdmistat/internal/statcollector"
"github.com/dustin/go-humanize"
)
// OverviewScreen displays system overview
type OverviewScreen struct{}
// NewOverviewScreen creates a new overview screen renderer
func NewOverviewScreen() *OverviewScreen {
return &OverviewScreen{}
}
// Name returns the name of this screen
func (s *OverviewScreen) Name() string {
return "System Overview"
}
// Render draws the overview screen to the provided canvas
func (s *OverviewScreen) Render(canvas *layout.Canvas, info *statcollector.SystemInfo) error {
width, height := canvas.Size()
_, _ = 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}
headerStyle := layout.TextStyle{Size: 18, Color: headerColor, Bold: true}
normalStyle := layout.TextStyle{Size: 18, Color: textColor}
smallStyle := layout.TextStyle{Size: 16, Color: dimColor}
y := 50
y := 120 // Start below header
// Title
_ = canvas.DrawText(info.Hostname, layout.Point{X: width / 2, Y: y}, layout.TextStyle{
Size: titleStyle.Size,
// Get short hostname
shortHostname := info.Hostname
if idx := strings.Index(shortHostname, "."); idx > 0 {
shortHostname = shortHostname[:idx]
}
// Title - left aligned at consistent position
titleText := fmt.Sprintf("%s: status", shortHostname)
_ = canvas.DrawText(titleText, layout.Point{X: 50, Y: y}, layout.TextStyle{
Size: 36, // Smaller than before
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,
Alignment: layout.AlignLeft,
})
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
}
// Standard bar dimensions
barWidth := 400
barHeight := 20
sectionSpacing := 60
// CPU section
_ = canvas.DrawText("CPU", layout.Point{X: 50, Y: y}, headerStyle)
y += 30
// Network section
_ = canvas.DrawText("NETWORK", layout.Point{X: leftX, Y: y}, headerStyle)
y += 35
// Calculate average CPU usage
totalCPU := 0.0
for _, cpu := range info.CPUPercent {
totalCPU += cpu
}
avgCPU := totalCPU / float64(len(info.CPUPercent))
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)
// Draw composite CPU bar below header
cpuBar := &layout.ProgressBar{
X: 50, Y: y,
Width: barWidth, Height: barHeight,
Value: avgCPU / 100.0,
Label: fmt.Sprintf("%.1f%% average across %d cores", avgCPU, len(info.CPUPercent)),
LeftLabel: "0%",
RightLabel: "100%",
BarColor: color.RGBA{255, 100, 100, 255},
}
cpuBar.Draw(canvas)
y += sectionSpacing
if len(net.IPAddresses) > 0 {
_ = canvas.DrawText(net.IPAddresses[0], layout.Point{X: leftX + 150, Y: y}, smallStyle)
}
// Memory section
_ = canvas.DrawText("MEMORY", layout.Point{X: 50, Y: y}, headerStyle)
y += 30
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)
memUsedPercent := float64(info.MemoryUsed) / float64(info.MemoryTotal)
memoryBar := &layout.ProgressBar{
X: 50, Y: y,
Width: barWidth, Height: barHeight,
Value: memUsedPercent,
Label: fmt.Sprintf("%s of %s", layout.FormatBytes(info.MemoryUsed), layout.FormatBytes(info.MemoryTotal)),
LeftLabel: "0B",
RightLabel: layout.FormatBytes(info.MemoryTotal),
BarColor: color.RGBA{100, 200, 100, 255},
}
memoryBar.Draw(canvas)
y += sectionSpacing
// Temperature section
if len(info.Temperature) > 0 {
_ = canvas.DrawText("TEMPERATURE", layout.Point{X: 50, Y: y}, headerStyle)
y += 30
// Find the highest temperature
maxTemp := 0.0
maxSensor := ""
for sensor, temp := range info.Temperature {
if temp > maxTemp {
maxTemp = temp
maxSensor = sensor
}
}
// Temperature scale from 30°C to 99°C
tempValue := (maxTemp - 30.0) / (99.0 - 30.0)
if tempValue < 0 {
tempValue = 0
}
if tempValue > 1 {
tempValue = 1
}
tempBar := &layout.ProgressBar{
X: 50, Y: y,
Width: barWidth, Height: barHeight,
Value: tempValue,
Label: fmt.Sprintf("%s: %.1f°C", maxSensor, maxTemp),
LeftLabel: "30°C",
RightLabel: "99°C",
BarColor: color.RGBA{255, 150, 50, 255},
}
tempBar.Draw(canvas)
y += sectionSpacing
}
// Temperature section (bottom right)
if len(info.Temperature) > 0 {
tempY := height - 200
_ = canvas.DrawText("TEMPERATURE", layout.Point{X: rightX, Y: tempY}, headerStyle)
tempY += 35
// Disk usage section
_ = canvas.DrawText("DISK USAGE", layout.Point{X: 50, Y: y}, headerStyle)
y += 30
for sensor, temp := range info.Temperature {
if tempY > height-50 {
break
for _, disk := range info.DiskUsage {
// Skip snap disks
if strings.HasPrefix(disk.Path, "/snap") {
continue
}
diskBar := &layout.ProgressBar{
X: 50, Y: y,
Width: barWidth, Height: barHeight,
Value: disk.UsedPercent / 100.0,
Label: fmt.Sprintf("%s: %s of %s", disk.Path, layout.FormatBytes(disk.Used), layout.FormatBytes(disk.Total)),
LeftLabel: "0B",
RightLabel: layout.FormatBytes(disk.Total),
BarColor: color.RGBA{200, 200, 100, 255},
}
diskBar.Draw(canvas)
y += 40
if y > 700 {
break // Don't overflow the screen
}
}
y += sectionSpacing
// Network section
if len(info.Network) > 0 {
_ = canvas.DrawText("NETWORK", layout.Point{X: 50, Y: y}, headerStyle)
y += 30
for _, net := range info.Network {
// Network interface info
interfaceText := net.Name
if len(net.IPAddresses) > 0 {
interfaceText = fmt.Sprintf("%s (%s)", net.Name, net.IPAddresses[0])
}
_ = canvas.DrawText(interfaceText, layout.Point{X: 50, Y: y}, normalStyle)
y += 25
// Get link speed for scaling (default to 1 Gbps if unknown)
linkSpeed := net.LinkSpeed
if linkSpeed == 0 {
linkSpeed = 1000 * 1000 * 1000 // 1 Gbps in bits
}
// TX rate bar
txValue := float64(net.BitsSentRate) / float64(linkSpeed)
txBar := &layout.ProgressBar{
X: 50, Y: y,
Width: barWidth/2 - 10, Height: barHeight,
Value: txValue,
Label: fmt.Sprintf("↑ %s", net.FormatSentRate()),
LeftLabel: "0",
RightLabel: humanize.SI(float64(linkSpeed), "bit/s"),
BarColor: color.RGBA{100, 255, 100, 255},
}
txBar.Draw(canvas)
// RX rate bar
rxValue := float64(net.BitsRecvRate) / float64(linkSpeed)
rxBar := &layout.ProgressBar{
X: 50 + barWidth/2 + 10, Y: y,
Width: barWidth/2 - 10, Height: barHeight,
Value: rxValue,
Label: fmt.Sprintf("↓ %s", net.FormatRecvRate()),
LeftLabel: "0",
RightLabel: humanize.SI(float64(linkSpeed), "bit/s"),
BarColor: color.RGBA{100, 100, 255, 255},
}
rxBar.Draw(canvas)
y += 60
if y > 900 {
break // Don't overflow the screen
}
_ = canvas.DrawText(fmt.Sprintf("%s: %.1f°C", sensor, temp),
layout.Point{X: rightX, Y: tempY}, normalStyle)
tempY += 25
}
}

View File

@ -4,24 +4,39 @@ import (
"fmt"
"image/color"
"sort"
"strings"
"git.eeqj.de/sneak/hdmistat/internal/layout"
"git.eeqj.de/sneak/hdmistat/internal/statcollector"
)
const (
// Display constants
maxProcessNameLen = 30
maxUsernameLen = 12
topProcessCount = 20
cpuHighThreshold = 50.0
memoryHighRatio = 0.1
percentMultiplier = 100.0
halfDivisor = 2
)
// ProcessScreen displays top processes
type ProcessScreen struct {
SortBy string // "cpu" or "memory"
}
// NewProcessScreenCPU creates a new process screen sorted by CPU usage
func NewProcessScreenCPU() *ProcessScreen {
return &ProcessScreen{SortBy: "cpu"}
}
// NewProcessScreenMemory creates a new process screen sorted by memory usage
func NewProcessScreenMemory() *ProcessScreen {
return &ProcessScreen{SortBy: "memory"}
}
// Name returns the name of this screen
func (s *ProcessScreen) Name() string {
if s.SortBy == "cpu" {
return "Top Processes by CPU"
@ -29,6 +44,7 @@ func (s *ProcessScreen) Name() string {
return "Top Processes by Memory"
}
// Render draws the process screen to the provided canvas
func (s *ProcessScreen) Render(canvas *layout.Canvas, info *statcollector.SystemInfo) error {
width, _ := canvas.Size()
@ -43,15 +59,27 @@ func (s *ProcessScreen) Render(canvas *layout.Canvas, info *statcollector.System
normalStyle := layout.TextStyle{Size: 16, Color: textColor}
smallStyle := layout.TextStyle{Size: 14, Color: dimColor}
y := 50
y := 120 // Start below header - same as overview
// Title
_ = canvas.DrawText(s.Name(), layout.Point{X: width / 2, Y: y}, layout.TextStyle{
Size: titleStyle.Size,
// Get short hostname
shortHostname := info.Hostname
if idx := strings.Index(shortHostname, "."); idx > 0 {
shortHostname = shortHostname[:idx]
}
// Title - left aligned at consistent position
titleText := ""
if s.SortBy == "cpu" {
titleText = fmt.Sprintf("%s: cpu", shortHostname)
} else {
titleText = fmt.Sprintf("%s: memory", shortHostname)
}
_ = canvas.DrawText(titleText, layout.Point{X: 50, Y: y}, layout.TextStyle{
Size: 36, // Same size as overview
Color: titleStyle.Color,
Alignment: layout.AlignCenter,
Alignment: layout.AlignLeft,
})
y += 70
y += 60
// Sort processes
processes := make([]statcollector.ProcessInfo, len(info.Processes))
@ -81,19 +109,26 @@ func (s *ProcessScreen) Render(canvas *layout.Canvas, info *statcollector.System
// Display top 20 processes
for i, proc := range processes {
if i >= 20 {
if i >= topProcessCount {
break
}
// Truncate long names
name := proc.Name
if len(name) > 30 {
name = name[:27] + "..."
if len(name) > maxProcessNameLen {
name = name[:maxProcessNameLen-3] + "..."
}
user := proc.Username
if len(user) > 12 {
user = user[:9] + "..."
if len(user) > maxUsernameLen {
user = user[:maxUsernameLen-3] + "..."
}
// Highlight bar for high usage (draw BEFORE text)
if s.SortBy == "cpu" && proc.CPUPercent > cpuHighThreshold {
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) > memoryHighRatio {
canvas.DrawBox(x-5, y-15, width-90, 20, color.RGBA{50, 50, 100, 100})
}
_ = canvas.DrawText(fmt.Sprintf("%d", proc.PID), layout.Point{X: x, Y: y}, normalStyle)
@ -102,13 +137,6 @@ func (s *ProcessScreen) Render(canvas *layout.Canvas, info *statcollector.System
_ = 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
}
@ -127,9 +155,9 @@ func (s *ProcessScreen) Render(canvas *layout.Canvas, info *statcollector.System
avgCPU,
layout.FormatBytes(info.MemoryUsed),
layout.FormatBytes(info.MemoryTotal),
float64(info.MemoryUsed)/float64(info.MemoryTotal)*100)
float64(info.MemoryUsed)/float64(info.MemoryTotal)*percentMultiplier)
_ = canvas.DrawText(footerText, layout.Point{X: width / 2, Y: y}, layout.TextStyle{
_ = canvas.DrawText(footerText, layout.Point{X: width / halfDivisor, Y: y}, layout.TextStyle{
Size: smallStyle.Size,
Color: smallStyle.Color,
Alignment: layout.AlignCenter,

View File

@ -2,7 +2,11 @@ package renderer
import (
"image"
"image/color"
"log/slog"
"os/exec"
"strings"
"time"
"git.eeqj.de/sneak/hdmistat/internal/layout"
"git.eeqj.de/sneak/hdmistat/internal/statcollector"
@ -43,9 +47,134 @@ func (r *Renderer) SetResolution(width, height int) {
func (r *Renderer) RenderScreen(screen Screen, info *statcollector.SystemInfo) (*image.RGBA, error) {
canvas := layout.NewCanvas(r.width, r.height, r.font, r.logger)
// Draw common header
r.drawHeader(canvas, info)
if err := screen.Render(canvas, info); err != nil {
return nil, err
}
return canvas.Image(), nil
}
// drawHeader draws the common header with system info
func (r *Renderer) drawHeader(canvas *layout.Canvas, _ *statcollector.SystemInfo) {
width, _ := canvas.Size()
headerColor := color.RGBA{150, 150, 150, 255}
headerStyle := layout.TextStyle{Size: 14, Color: headerColor, Bold: true}
// Get uname info
uname := "Unknown System"
if output, err := exec.Command("uname", "-a").Output(); err == nil {
uname = strings.TrimSpace(string(output))
// Truncate if too long
if len(uname) > 150 {
uname = uname[:147] + "..."
}
}
// Draw uname on left
_ = canvas.DrawText(uname, layout.Point{X: 20, Y: 20}, headerStyle)
// Check NTP sync status
ntpSynced := r.checkNTPSync()
var syncIndicator string
var syncColor color.Color
if ntpSynced {
syncIndicator = "*"
syncColor = color.RGBA{0, 255, 0, 255} // Green
} else {
syncIndicator = "?"
syncColor = color.RGBA{255, 0, 0, 255} // Red
}
// Time formats
now := time.Now()
utcTime := now.UTC().Format("Mon 2006-01-02 15:04:05 UTC")
localTime := now.Format("Mon 2006-01-02 15:04:05 MST")
// Draw times on the right with sync indicators
// For simplicity, we'll use a fixed position approach
// Draw UTC time
_ = canvas.DrawText(utcTime, layout.Point{X: width - 40, Y: 20}, layout.TextStyle{
Size: headerStyle.Size,
Color: color.RGBA{255, 255, 255, 255}, // White
Alignment: layout.AlignRight,
Bold: true,
})
// UTC sync indicators
_ = canvas.DrawText(syncIndicator, layout.Point{X: width - 280, Y: 20}, layout.TextStyle{
Size: headerStyle.Size,
Color: syncColor,
Bold: true,
})
_ = canvas.DrawText(syncIndicator, layout.Point{X: width - 20, Y: 20}, layout.TextStyle{
Size: headerStyle.Size,
Color: syncColor,
Bold: true,
})
// Draw local time
_ = canvas.DrawText(localTime, layout.Point{X: width - 40, Y: 35}, layout.TextStyle{
Size: headerStyle.Size,
Color: color.RGBA{255, 255, 255, 255}, // White
Alignment: layout.AlignRight,
Bold: true,
})
// Local sync indicators
_ = canvas.DrawText(syncIndicator, layout.Point{X: width - 280, Y: 35}, layout.TextStyle{
Size: headerStyle.Size,
Color: syncColor,
Bold: true,
})
_ = canvas.DrawText(syncIndicator, layout.Point{X: width - 20, Y: 35}, layout.TextStyle{
Size: headerStyle.Size,
Color: syncColor,
Bold: true,
})
// Get uptime command output
uptimeStr := "uptime unavailable"
if output, err := exec.Command("uptime").Output(); err == nil {
uptimeStr = strings.TrimSpace(string(output))
}
// Draw uptime on second line
_ = canvas.DrawText(uptimeStr, layout.Point{X: 20, Y: 40}, headerStyle)
// Draw horizontal rule with more space
canvas.DrawHLine(0, 70, width, color.RGBA{100, 100, 100, 255})
}
// checkNTPSync checks if the system clock is synchronized with NTP
func (r *Renderer) checkNTPSync() bool {
// Try timedatectl first (systemd systems)
if output, err := exec.Command("timedatectl", "status").Output(); err == nil {
outputStr := string(output)
// Look for "System clock synchronized: yes" or "NTP synchronized: yes"
if strings.Contains(outputStr, "synchronized: yes") {
return true
}
return false
}
// Try chronyc (chrony)
if output, err := exec.Command("chronyc", "tracking").Output(); err == nil {
outputStr := string(output)
// Look for "Leap status : Normal"
if strings.Contains(outputStr, "Leap status : Normal") {
return true
}
return false
}
// Try ntpstat (ntpd)
if err := exec.Command("ntpstat").Run(); err == nil {
// ntpstat returns 0 if synchronized
return true
}
// Default to not synced if we can't determine
return false
}

View File

@ -0,0 +1,246 @@
package renderer
import (
"fmt"
"image/color"
"strings"
"git.eeqj.de/sneak/hdmistat/internal/layout"
"git.eeqj.de/sneak/hdmistat/internal/statcollector"
"github.com/dustin/go-humanize"
)
// StatusScreen displays system status overview
type StatusScreen struct{}
// NewStatusScreen creates a new status screen
func NewStatusScreen() *StatusScreen {
return &StatusScreen{}
}
// Name returns the screen name
func (s *StatusScreen) Name() string {
return "System Status"
}
// Render renders the status screen
func (s *StatusScreen) Render(canvas *layout.Canvas, info *statcollector.SystemInfo) error {
// Use consistent font size for entire screen
const fontSize = 16
// Colors
textColor := color.RGBA{255, 255, 255, 255}
dimColor := color.RGBA{150, 150, 150, 255}
// Styles
normalStyle := layout.TextStyle{Size: fontSize, Color: textColor}
dimStyle := layout.TextStyle{Size: fontSize, Color: dimColor}
// Get short hostname
shortHostname := info.Hostname
if idx := strings.Index(shortHostname, "."); idx > 0 {
shortHostname = shortHostname[:idx]
}
// Starting Y position (after header)
y := 150
// Title
titleText := fmt.Sprintf("%s: system status", shortHostname)
_ = canvas.DrawText(titleText, layout.Point{X: 16, Y: y}, normalStyle)
y += 40
// CPU section
cpuLabel := fmt.Sprintf("CPU: %.1f%% average across %d cores",
getAverageCPU(info.CPUPercent), len(info.CPUPercent))
_ = canvas.DrawText(cpuLabel, layout.Point{X: 16, Y: y}, normalStyle)
y += 25
// CPU progress bar
_ = canvas.DrawText("0%", layout.Point{X: 100, Y: y}, dimStyle)
drawProgressBar(canvas, 130, y-10, getAverageCPU(info.CPUPercent)/100.0, textColor)
_ = canvas.DrawText("100%", layout.Point{X: 985, Y: y}, dimStyle)
y += 40
// Memory section
memUsedPercent := float64(info.MemoryUsed) / float64(info.MemoryTotal) * 100.0
memLabel := fmt.Sprintf("MEMORY: %s of %s (%.1f%%)",
layout.FormatBytes(info.MemoryUsed),
layout.FormatBytes(info.MemoryTotal),
memUsedPercent)
_ = canvas.DrawText(memLabel, layout.Point{X: 16, Y: y}, normalStyle)
y += 25
// Memory progress bar
_ = canvas.DrawText("0B", layout.Point{X: 100, Y: y}, dimStyle)
drawProgressBar(canvas, 130, y-10, float64(info.MemoryUsed)/float64(info.MemoryTotal), textColor)
_ = canvas.DrawText(layout.FormatBytes(info.MemoryTotal), layout.Point{X: 985, Y: y}, dimStyle)
y += 40
// Temperature section
if len(info.Temperature) > 0 {
maxTemp, maxSensor := getMaxTemperature(info.Temperature)
tempLabel := fmt.Sprintf("TEMPERATURE: %.1f°C (%s)", maxTemp, maxSensor)
_ = canvas.DrawText(tempLabel, layout.Point{X: 16, Y: y}, normalStyle)
y += 25
// Temperature progress bar (30-99°C scale)
_ = canvas.DrawText("30°C", layout.Point{X: 90, Y: y}, dimStyle)
tempValue := (maxTemp - 30.0) / (99.0 - 30.0)
if tempValue < 0 {
tempValue = 0
}
if tempValue > 1 {
tempValue = 1
}
drawProgressBar(canvas, 130, y-10, tempValue, textColor)
_ = canvas.DrawText("99°C", layout.Point{X: 985, Y: y}, dimStyle)
y += 40
}
// Disk usage section
_ = canvas.DrawText("DISK USAGE:", layout.Point{X: 16, Y: y}, normalStyle)
y += 25
for _, disk := range info.DiskUsage {
// Skip snap disks
if strings.HasPrefix(disk.Path, "/snap") {
continue
}
diskLabel := fmt.Sprintf(" * %-12s %s of %s (%.1f%%)",
disk.Path,
layout.FormatBytes(disk.Used),
layout.FormatBytes(disk.Total),
disk.UsedPercent)
_ = canvas.DrawText(diskLabel, layout.Point{X: 16, Y: y}, normalStyle)
// Disk progress bar
_ = canvas.DrawText("0B", layout.Point{X: 470, Y: y}, dimStyle)
drawDiskProgressBar(canvas, 500, y-10, disk.UsedPercent/100.0, textColor)
_ = canvas.DrawText(layout.FormatBytes(disk.Total), layout.Point{X: 985, Y: y}, dimStyle)
y += 30
if y > 700 {
break // Don't overflow
}
}
// Network section
if len(info.Network) > 0 {
y += 15
_ = canvas.DrawText("NETWORK:", layout.Point{X: 16, Y: y}, normalStyle)
y += 25
for _, net := range info.Network {
// Interface header
interfaceText := fmt.Sprintf(" * %s", net.Name)
if len(net.IPAddresses) > 0 {
interfaceText = fmt.Sprintf(" * %s (%s):", net.Name, net.IPAddresses[0])
}
_ = canvas.DrawText(interfaceText, layout.Point{X: 16, Y: y}, normalStyle)
y += 25
// Get link speed for scaling (default to 1 Gbps if unknown)
linkSpeed := net.LinkSpeed
linkSpeedText := ""
if linkSpeed == 0 {
linkSpeed = 1000 * 1000 * 1000 // 1 Gbps in bits
linkSpeedText = "1G link"
} else {
linkSpeedText = fmt.Sprintf("%s link", humanize.SI(float64(linkSpeed), "bit/s"))
}
// Upload rate
upLabel := fmt.Sprintf(" ↑ %7s (%s)", net.FormatSentRate(), linkSpeedText)
_ = canvas.DrawText(upLabel, layout.Point{X: 16, Y: y}, normalStyle)
_ = canvas.DrawText("0 bit/s", layout.Point{X: 400, Y: y}, dimStyle)
drawNetworkProgressBar(canvas, 500, y-10, float64(net.BitsSentRate)/float64(linkSpeed), textColor)
_ = canvas.DrawText(humanize.SI(float64(linkSpeed), "bit/s"), layout.Point{X: 960, Y: y}, dimStyle)
y += 25
// Download rate
downLabel := fmt.Sprintf(" ↓ %7s", net.FormatRecvRate())
_ = canvas.DrawText(downLabel, layout.Point{X: 16, Y: y}, normalStyle)
_ = canvas.DrawText("0 bit/s", layout.Point{X: 400, Y: y}, dimStyle)
drawNetworkProgressBar(canvas, 500, y-10, float64(net.BitsRecvRate)/float64(linkSpeed), textColor)
_ = canvas.DrawText(humanize.SI(float64(linkSpeed), "bit/s"), layout.Point{X: 960, Y: y}, dimStyle)
y += 35
if y > 900 {
break
}
}
}
return nil
}
// drawProgressBar draws a progress bar matching the mockup style
func drawProgressBar(canvas *layout.Canvas, x, y int, value float64, color color.Color) {
const barWidth = 850
// Draw opening bracket
_ = canvas.DrawText("[", layout.Point{X: x, Y: y + 15}, layout.TextStyle{Size: 16, Color: color})
// Calculate fill
fillChars := int(value * 80)
emptyChars := 80 - fillChars
// Draw bar content
barContent := strings.Repeat("█", fillChars) + strings.Repeat("▒", emptyChars)
_ = canvas.DrawText(barContent, layout.Point{X: x + 10, Y: y + 15}, layout.TextStyle{Size: 16, Color: color})
// Draw closing bracket
_ = canvas.DrawText("]", layout.Point{X: x + barWidth - 10, Y: y + 15}, layout.TextStyle{Size: 16, Color: color})
}
// drawDiskProgressBar draws a smaller progress bar for disk usage
func drawDiskProgressBar(canvas *layout.Canvas, x, y int, value float64, color color.Color) {
const barWidth = 480
// Draw opening bracket
_ = canvas.DrawText("[", layout.Point{X: x, Y: y + 15}, layout.TextStyle{Size: 16, Color: color})
// Calculate fill (50 chars total)
fillChars := int(value * 50)
emptyChars := 50 - fillChars
// Draw bar content
barContent := strings.Repeat("█", fillChars) + strings.Repeat("▒", emptyChars)
_ = canvas.DrawText(barContent, layout.Point{X: x + 10, Y: y + 15}, layout.TextStyle{Size: 16, Color: color})
// Draw closing bracket
_ = canvas.DrawText("]", layout.Point{X: x + barWidth - 10, Y: y + 15}, layout.TextStyle{Size: 16, Color: color})
}
// drawNetworkProgressBar draws a progress bar for network rates
func drawNetworkProgressBar(canvas *layout.Canvas, x, y int, value float64, color color.Color) {
// Same as disk progress bar
drawDiskProgressBar(canvas, x, y, value, color)
}
// getAverageCPU calculates average CPU usage across all cores
func getAverageCPU(cpuPercents []float64) float64 {
if len(cpuPercents) == 0 {
return 0
}
total := 0.0
for _, cpu := range cpuPercents {
total += cpu
}
return total / float64(len(cpuPercents))
}
// getMaxTemperature finds the highest temperature and its sensor name
func getMaxTemperature(temps map[string]float64) (float64, string) {
maxTemp := 0.0
maxSensor := ""
for sensor, temp := range temps {
if temp > maxTemp {
maxTemp = temp
maxSensor = sensor
}
}
return maxTemp, maxSensor
}

View File

@ -1,11 +1,17 @@
// Package statcollector provides system information collection
package statcollector
import (
"log/slog"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
"git.eeqj.de/sneak/hdmistat/internal/netmon"
"github.com/dustin/go-humanize"
"github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/disk"
"github.com/shirou/gopsutil/v3/host"
@ -14,6 +20,17 @@ import (
"github.com/shirou/gopsutil/v3/process"
)
const (
// Process collection constants
maxProcesses = 100
processTimeout = 50 * time.Millisecond
processStableTime = 100 * time.Millisecond
msToSecondsDivisor = 1000
// Network constants
bitsPerMegabit = 1000 * 1000
)
// SystemInfo represents overall system information
type SystemInfo struct {
Hostname string
@ -40,13 +57,23 @@ type DiskInfo struct {
// NetworkInfo represents network interface information
type NetworkInfo struct {
Name string
IPAddresses []string
LinkSpeed uint64
BytesSent uint64
BytesRecv uint64
PacketsSent uint64
PacketsRecv uint64
Name string
IPAddresses []string
LinkSpeed uint64
BytesSent uint64
BytesRecv uint64
BitsSentRate uint64 // bits per second
BitsRecvRate uint64 // bits per second
}
// FormatSentRate returns the send rate as a human-readable string
func (n *NetworkInfo) FormatSentRate() string {
return humanize.SI(float64(n.BitsSentRate), "bit/s")
}
// FormatRecvRate returns the receive rate as a human-readable string
func (n *NetworkInfo) FormatRecvRate() string {
return humanize.SI(float64(n.BitsRecvRate), "bit/s")
}
// ProcessInfo represents process information
@ -67,15 +94,25 @@ type Collector interface {
// SystemCollector implements Collector
type SystemCollector struct {
logger *slog.Logger
lastNetStats map[string]psnet.IOCountersStat
netMonitor *netmon.Monitor
lastCollectTime time.Time
}
// NewSystemCollector creates a new system collector
func NewSystemCollector(logger *slog.Logger) *SystemCollector {
nm := netmon.New(logger)
nm.Start()
return &SystemCollector{
logger: logger,
lastNetStats: make(map[string]psnet.IOCountersStat),
logger: logger,
netMonitor: nm,
}
}
// Stop stops the system collector
func (c *SystemCollector) Stop() {
if c.netMonitor != nil {
c.netMonitor.Stop()
}
}
@ -100,7 +137,13 @@ func (c *SystemCollector) Collect() (*SystemInfo, error) {
if err != nil {
c.logger.Warn("getting uptime", "error", err)
} else {
info.Uptime = time.Duration(uptimeSecs) * time.Second
if uptimeSecs > 0 {
// Convert uint64 to int64 safely to avoid overflow
maxInt64 := ^uint64(0) >> 1
if uptimeSecs <= maxInt64 {
info.Uptime = time.Duration(int64(uptimeSecs)) * time.Second
}
}
}
// Memory
@ -160,37 +203,52 @@ func (c *SystemCollector) Collect() (*SystemInfo, error) {
}
}
// Network
// Network - get rates from network monitor
netStats := c.netMonitor.GetStats()
// Also get interface details for IP addresses
interfaces, err := psnet.Interfaces()
if err != nil {
c.logger.Warn("getting network interfaces", "error", err)
} else {
ioCounters, _ := psnet.IOCounters(true)
ioMap := make(map[string]psnet.IOCountersStat)
for _, counter := range ioCounters {
ioMap[counter.Name] = counter
}
// Create a map of interface names to IPs and link speeds
ifaceIPs := make(map[string][]string)
ifaceSpeeds := make(map[string]uint64)
for _, iface := range interfaces {
if iface.Name == "lo" || strings.HasPrefix(iface.Name, "docker") {
continue
}
netInfo := NetworkInfo{
Name: iface.Name,
}
// Get IP addresses
var ips []string
for _, addr := range iface.Addrs {
netInfo.IPAddresses = append(netInfo.IPAddresses, addr.Addr)
ips = append(ips, addr.Addr)
}
ifaceIPs[iface.Name] = ips
// Try to get link speed with ethtool
if speed := c.getLinkSpeed(iface.Name); speed > 0 {
ifaceSpeeds[iface.Name] = speed
}
}
// Combine network monitor stats with interface details
for _, stat := range netStats {
netInfo := NetworkInfo{
Name: stat.Name,
BytesSent: stat.BytesSent,
BytesRecv: stat.BytesRecv,
BitsSentRate: stat.BitsSentRate,
BitsRecvRate: stat.BitsRecvRate,
}
// Get stats
if stats, ok := ioMap[iface.Name]; ok {
netInfo.BytesSent = stats.BytesSent
netInfo.BytesRecv = stats.BytesRecv
netInfo.PacketsSent = stats.PacketsSent
netInfo.PacketsRecv = stats.PacketsRecv
// Add IP addresses if available
if ips, ok := ifaceIPs[stat.Name]; ok {
netInfo.IPAddresses = ips
}
// Add link speed if available
if speed, ok := ifaceSpeeds[stat.Name]; ok {
netInfo.LinkSpeed = speed
}
info.Network = append(info.Network, netInfo)
@ -202,9 +260,43 @@ func (c *SystemCollector) Collect() (*SystemInfo, error) {
if err != nil {
c.logger.Warn("getting processes", "error", err)
} else {
// Limit to top processes to avoid hanging
processCount := 0
for _, p := range processes {
name, _ := p.Name()
cpuPercent, _ := p.CPUPercent()
if processCount >= maxProcesses {
break
}
// Skip kernel threads and very short-lived processes
name, err := p.Name()
if err != nil || name == "" {
continue
}
// Use CreateTime to skip very new processes that might not have stable stats
createTime, err := p.CreateTime()
if err != nil || time.Since(time.Unix(createTime/msToSecondsDivisor, 0)) < processStableTime {
continue
}
// Get CPU percent with timeout - this is the call that can hang
cpuPercent := 0.0
cpuChan := make(chan float64, 1)
go func() {
cpu, _ := p.CPUPercent()
cpuChan <- cpu
}()
select {
case cpu := <-cpuChan:
cpuPercent = cpu
case <-time.After(processTimeout):
// Skip this process if CPU sampling takes too long
c.logger.Debug("skipping process due to CPU timeout", "pid", p.Pid, "name", name)
continue
}
memInfo, _ := p.MemoryInfo()
username, _ := p.Username()
@ -216,9 +308,36 @@ func (c *SystemCollector) Collect() (*SystemInfo, error) {
MemoryVMS: memInfo.VMS,
Username: username,
})
processCount++
}
}
c.lastCollectTime = time.Now()
return info, nil
}
// getLinkSpeed gets the link speed for an interface using ethtool
func (c *SystemCollector) getLinkSpeed(ifaceName string) uint64 {
// Run ethtool to get link speed
output, err := exec.Command("ethtool", ifaceName).Output()
if err != nil {
return 0
}
// Parse the output for speed
// Look for lines like "Speed: 1000Mb/s" or "Speed: 10000Mb/s"
speedRegex := regexp.MustCompile(`Speed:\s+(\d+)Mb/s`)
matches := speedRegex.FindSubmatch(output)
if len(matches) < 2 {
return 0
}
// Convert from Mb/s to bits/s
mbps, err := strconv.ParseUint(string(matches[1]), 10, 64)
if err != nil {
return 0
}
return mbps * bitsPerMegabit // Convert to bits per second
}

View File

@ -2,165 +2,189 @@
SCREEN 1: STATUS (Overview)
================================================================================
x12345678911234567892123456789312345678941234567895123456789612345678971234567898123456789912345678901234567891x
2Linux 5.15.0-119-generic #129-Ubuntu SMP aarch64 *Thu 2025-07-24 01:17:36 UTC*
4 03:18:07 up 11 days 21:09, 10 users, load average: 6.58, 8.99, 8.21 *Wed 2025-07-23 21:17:36 EDT*
5--------------------------------------------------------------------------------------------------------------x
6
7ubuntu-vm: system status
8
2Linux 5.15.0-119-generic #129-Ubuntu SMP aarch64 *Thu 2025-07-24 01:17:36 UTC*x
4 03:18:07 up 11 days 21:09, 10 users, load average: 6.58, 8.99, 8.21 *Wed 2025-07-23 21:17:36 EDT*x
5──────────────────────────────────────────────────────────────────────────────────────────────────────────────x
6 x
7ubuntu-vm: system status x
8 x
9CPU: 32.5% average across 4 cores
1 0% [████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 100%
1 0% [████████████████████▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒] 100%
1
2MEMORY: 3.2GB of 7.8GB (x.x%)
3 0B [██████████████████████████████░░░░░░░ ░░░░░░░░░░░░░] 7.8GB
2MEMORY: 3.2GB of 7.8GB (41.0%)
3 0B [██████████████████████████████▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒] 7.8GB
4
5TEMPERATURE: 52.3°C (coretemp)
6 30°C [████████████████████████░░░░░░░░░░░░░░░░ ░░░░░░░░░░] 99°C
6 30°C [████████████████████████▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒] 99°C
7
8DISK USAGE:
9 * /: 12.3GB of 98.5GB (12.5%) 0B [████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 98.5GB
2 * /boot: 489.2MB of 985.0MB (49.7%) 0B [███████████████████████████ ███████░] 985MB
### AGENT: COMPLETE THE REST. follow the format. the numbered first row
### and column are not part of the layout, they are simply a frame.
### repeat the top three line header on all screens, and replace the dashes
### with proper unicode horizontal rules. Update the network section to
### like the disk section
NETWORK
eth0 (192.168.1.100)
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] ↑ 1.2 Mbit/s
0 1 Gbit/s
[████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] ↓ 82.5 Mbit/s
0 1 Gbit/s
wlan0 (10.0.0.42)
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] ↑ 524.3 kbit/s
0 300 Mbit/s
[███░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] ↓ 18.7 Mbit/s
0 300 Mbit/s
9 * /: 12.3GB of 98.5GB (12.5%) 0B [████████▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒] 98.5GB
2 * /boot: 489.2MB of 985.0MB (49.7%) 0B [█████████████████████████████▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒] 985MB
3
4NETWORK:
5 * eth0 (192.168.1.100):
6 ↑ 1.2 Mbit/s (1G link) 0 bit/s [▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒] 1 Gbit/s
7 ↓ 82.5 Mbit/s 0 bit/s [████▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒] 1 Gbit/s
8
9 * wlan0 (10.0.0.42):
3 ↑ 524.3 kbit/s (300M link) 0 [▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒] 300 Mbit/s
1 ↓ 18.7 Mbit/s 0 [███▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒] 300 Mbit/s
2
3
4
5
6
7
8
9
4
x12345678911234567892123456789312345678941234567895123456789612345678971234567898123456789912345678901234567891x
================================================================================
SCREEN 2: CPU (Top Processes by CPU)
================================================================================
ubuntu-vm Wed 17:53:22 UTC * Wed 19:53:22 CEST *
Linux 5.15.0-119-generic #129-Ubuntu SMP Fri Aug 2 19:25:20 UTC 2024 aarch64 up 1 day, 18:14
ubuntu-vm: cpu
PID USER PROCESS CPU % MEMORY
───────────────────────────────────────────────────────────────────────────────
1234 root systemd 0.2 48.2 MB
2345 user firefox 15.8 1.2 GB
████████████████████████████████████████████████████████████████████████████████ (highlight)
3456 user vscode 42.1 892.5 MB
████████████████████████████████████████████████████████████████████████████████ (highlight)
4567 root dockerd 8.3 234.1 MB
5678 postgres postgres 2.1 156.8 MB
6789 user chrome 18.7 1.5 GB
7890 user node 5.2 312.4 MB
8901 mysql mysqld 3.8 445.2 MB
9012 user python3 1.2 89.3 MB
1122 user slack 4.5 567.8 MB
2233 root nginx 0.8 23.4 MB
3344 user spotify 2.3 234.5 MB
4455 user terminal 0.5 45.6 MB
5566 redis redis-server 1.1 67.8 MB
6677 user code-server 3.2 456.7 MB
7788 root sshd 0.1 12.3 MB
8899 user zoom 6.7 789.0 MB
9900 user discord 3.4 345.6 MB
1010 root snapd 0.3 56.7 MB
2020 user steam 4.8 567.8 MB
───────────────────────────────────────────────────────────────────────────────
System: CPU 32.5% | Memory: 3.2GB / 7.8GB (41.0%)
x12345678911234567892123456789312345678941234567895123456789612345678971234567898123456789912345678901234567891x
2Linux 5.15.0-119-generic #129-Ubuntu SMP aarch64 *Thu 2025-07-24 01:17:41 UTC*
4 03:18:12 up 11 days 21:09, 10 users, load average: 6.58, 8.99, 8.21 *Wed 2025-07-23 21:17:41 EDT*
5──────────────────────────────────────────────────────────────────────────────────────────────────────────────x
6
7ubuntu-vm: cpu
8
9CPU: 32.5% average across 4 cores
1 0% [████████████████████▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒] 100%
1
2MEMORY: 3.2GB of 7.8GB (41.0%)
3 0B [██████████████████████████████▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒] 7.8GB
4
5 PID USER PROCESS CPU% MEMORY
1 ──── ──────────── ────────────────────────────────────────────── ───── ─────────
1 3456 user vscode 42.1 892.5 MB █████████████████████████
2 6789 user chrome 18.7 1.5 GB █████████████████████████
3 2345 user firefox 15.8 1.2 GB
4 4567 root dockerd 8.3 234.1 MB
5 8899 user zoom 6.7 789.0 MB
6 7890 user node 5.2 312.4 MB
7 2020 user steam 4.8 567.8 MB
8 1122 user slack 4.5 567.8 MB
9 8901 mysql mysqld 3.8 445.2 MB
2 9900 user discord 3.4 345.6 MB
1 6677 user code-server 3.2 456.7 MB
2 3344 user spotify 2.3 234.5 MB
3 5678 postgres postgres 2.1 156.8 MB
4 9012 user python3 1.2 89.3 MB
5 5566 redis redis-server 1.1 67.8 MB
6 2233 root nginx 0.8 23.4 MB
7 4455 user terminal 0.5 45.6 MB
8 1010 root snapd 0.3 56.7 MB
9 1234 root systemd 0.2 48.2 MB
3 7788 root sshd 0.1 12.3 MB
1 ──── ──────────── ────────────────────────────────────────────── ───── ─────────
2 System totals: CPU: 32.5% average across 4 cores | Memory: 3.2GB / 7.8GB (41.0%)
3
4
5
6
7
8
9
4
x12345678911234567892123456789312345678941234567895123456789612345678971234567898123456789912345678901234567891x
================================================================================
SCREEN 3: MEMORY (Top Processes by Memory)
================================================================================
ubuntu-vm Wed 17:53:27 UTC ? Wed 19:53:27 CEST ?
Linux 5.15.0-119-generic #129-Ubuntu SMP Fri Aug 2 19:25:20 UTC 2024 aarch64 up 1 day, 18:14
ubuntu-vm: memory
PID USER PROCESS CPU % MEMORY
───────────────────────────────────────────────────────────────────────────────
6789 user chrome 18.7 1.5 GB
████████████████████████████████████████████████████████████████████████████████ (highlight)
2345 user firefox 15.8 1.2 GB
████████████████████████████████████████████████████████████████████████████████ (highlight)
3456 user vscode 42.1 892.5 MB
8899 user zoom 6.7 789.0 MB
1122 user slack 4.5 567.8 MB
2020 user steam 4.8 567.8 MB
6677 user code-server 3.2 456.7 MB
8901 mysql mysqld 3.8 445.2 MB
9900 user discord 3.4 345.6 MB
7890 user node 5.2 312.4 MB
3344 user spotify 2.3 234.5 MB
4567 root dockerd 8.3 234.1 MB
5678 postgres postgres 2.1 156.8 MB
9012 user python3 1.2 89.3 MB
5566 redis redis-server 1.1 67.8 MB
1010 root snapd 0.3 56.7 MB
1234 root systemd 0.2 48.2 MB
4455 user terminal 0.5 45.6 MB
2233 root nginx 0.8 23.4 MB
7788 root sshd 0.1 12.3 MB
───────────────────────────────────────────────────────────────────────────────
System: CPU 32.5% | Memory: 3.2GB / 7.8GB (41.0%)
x12345678911234567892123456789312345678941234567895123456789612345678971234567898123456789912345678901234567891x
2Linux 5.15.0-119-generic #129-Ubuntu SMP aarch64 ?Thu 2025-07-24 01:17:46 UTC?
4 03:18:17 up 11 days 21:09, 10 users, load average: 6.58, 8.99, 8.21 ?Wed 2025-07-23 21:17:46 EDT?
5──────────────────────────────────────────────────────────────────────────────────────────────────────────────x
6
7ubuntu-vm: memory
8
9 PID USER PROCESS CPU% MEMORY
1 ──── ──────────── ────────────────────────────────────────────── ───── ─────────
1 6789 user chrome 18.7 1.5 GB █████████████████████████
2 2345 user firefox 15.8 1.2 GB █████████████████████████
3 3456 user vscode 42.1 892.5 MB
4 8899 user zoom 6.7 789.0 MB
5 1122 user slack 4.5 567.8 MB
6 2020 user steam 4.8 567.8 MB
7 6677 user code-server 3.2 456.7 MB
8 8901 mysql mysqld 3.8 445.2 MB
9 9900 user discord 3.4 345.6 MB
2 7890 user node 5.2 312.4 MB
1 3344 user spotify 2.3 234.5 MB
2 4567 root dockerd 8.3 234.1 MB
3 5678 postgres postgres 2.1 156.8 MB
4 9012 user python3 1.2 89.3 MB
5 5566 redis redis-server 1.1 67.8 MB
6 1010 root snapd 0.3 56.7 MB
7 1234 root systemd 0.2 48.2 MB
8 4455 user terminal 0.5 45.6 MB
9 2233 root nginx 0.8 23.4 MB
3 7788 root sshd 0.1 12.3 MB
1 ──── ──────────── ────────────────────────────────────────────── ───── ─────────
2 System totals: CPU: 32.5% average across 4 cores | Memory: 3.2GB / 7.8GB (41.0%)
3
4
5
6
7
8
9
4
x12345678911234567892123456789312345678941234567895123456789612345678971234567898123456789912345678901234567891x
================================================================================
SCREEN 4: WEATHER (New)
================================================================================
ubuntu-vm Wed 17:53:32 UTC * Wed 19:53:32 CEST *
Linux 5.15.0-119-generic #129-Ubuntu SMP Fri Aug 2 19:25:20 UTC 2024 aarch64 up 1 day, 18:14
ubuntu-vm: weather
CURRENT CONDITIONS - Malmö, Sweden
Temperature: 21°C (71°F)
Feels Like: 21°C (71°F)
Weather: Partly cloudy
Humidity: 69%
Wind: WSW 8 km/h (5 mph)
Pressure: 1006 mb
Visibility: 10 km (6 mi)
UV Index: 1
FORECAST - Today (Wed, Jul 23)
[██████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░] Temperature Range
13°C 24°C
Morning Afternoon Evening Night
☁️ 15°C ⛅ 22°C ☁️ 19°C 🌧️ 13°C
Cloudy Partly cloudy Cloudy Light rain
TOMORROW - Thu, Jul 24
[████████████████████████████░░░░░░░░░░░░░░░░░░░░░░] Temperature Range
11°C 19°C
☔ Rainy day expected - 85% chance of precipitation
High: 19°C Low: 11°C Wind: W 15 km/h
3-DAY OUTLOOK
Fri: ⛅ 17°C/12°C Partly cloudy
Sat: ☀️ 20°C/14°C Sunny
Sun: ☁️ 18°C/13°C Cloudy
Sunrise: 04:58 Sunset: 21:30 Moon: 🌙 Waning Crescent (4%)
x12345678911234567892123456789312345678941234567895123456789612345678971234567898123456789912345678901234567891x
2Linux 5.15.0-119-generic #129-Ubuntu SMP aarch64 *Thu 2025-07-24 01:17:51 UTC*
4 03:18:22 up 11 days 21:09, 10 users, load average: 6.58, 8.99, 8.21 *Wed 2025-07-23 21:17:51 EDT*
5──────────────────────────────────────────────────────────────────────────────────────────────────────────────x
6
7ubuntu-vm: weather
8
9CURRENT CONDITIONS Malmö, Sweden (55.600°N, 13.000°E)
1
1Temperature: 21°C (71°F) Partly cloudy
2Feels Like: 21°C (71°F)
3Humidity: 69% Sunrise: 04:58 Sunset: 21:30
4Wind: WSW 8 km/h (5 mph) Moon: Waning Crescent (4%)
5Pressure: 1006 mb
6Visibility: 10 km (6 mi)
7UV Index: 1 (Low)
8
9TODAY (Wednesday, July 23)
2
1 Low [██████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░] High
2 13°C 24°C
3
4 06:00 09:00 12:00 15:00 18:00 21:00 00:00
5 15°C ☁️ 17°C ☁️ 22°C ⛅ 23°C ⛅ 19°C ☁️ 16°C 🌧️ 13°C 🌧️
6 Cloudy Cloudy Partly Partly Cloudy Light Light
7 cloudy cloudy rain rain
8
9TOMORROW (Thursday, July 24)
3
1 Low [████████████████████████████░░░░░░░░░░░░░░░░░░░░░░] High
2 11°C 19°C
3
4☔ Rainy day - 85% chance of precipitation throughout the day
5Morning: 14°C Afternoon: 18°C Evening: 15°C Night: 11°C
6Wind: W 15 km/h gusting to 25 km/h
7
83-DAY FORECAST
9 Fri Jul 25: 17°C/12°C ⛅ Partly cloudy 30% rain
4 Sat Jul 26: 20°C/14°C ☀️ Sunny 10% rain
1 Sun Jul 27: 18°C/13°C ☁️ Cloudy 40% rain
2
x12345678911234567892123456789312345678941234567895123456789612345678971234567898123456789912345678901234567891x
================================================================================

248
scripts/docker-fb-test.sh Executable file
View File

@ -0,0 +1,248 @@
#!/bin/bash
set -euo pipefail
# Docker-based framebuffer testing for hdmistat
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
# Colors
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[0;33m'
NC='\033[0m'
log() {
echo -e "${GREEN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $*"
}
error() {
echo -e "${RED}[ERROR]${NC} $*" >&2
exit 1
}
warn() {
echo -e "${YELLOW}[WARN]${NC} $*"
}
# Create Dockerfile
create_dockerfile() {
log "Creating Dockerfile for framebuffer testing..."
cat > "${PROJECT_DIR}/Dockerfile.fbtest" << 'EOF'
FROM ubuntu:22.04
# Avoid interactive prompts
ENV DEBIAN_FRONTEND=noninteractive
# Install dependencies
RUN apt-get update && apt-get install -y \
# Build tools
build-essential \
git \
wget \
golang-go \
# Framebuffer tools
fbset \
fbi \
# Virtual framebuffer
xvfb \
x11vnc \
# System tools
systemd \
systemd-sysv \
sudo \
htop \
procps \
&& rm -rf /var/lib/apt/lists/*
# Install Go 1.24.4 (ARM64 compatible)
RUN ARCH=$(dpkg --print-architecture) && \
GO_ARCH=$([ "$ARCH" = "arm64" ] && echo "arm64" || echo "amd64") && \
wget -q -O /tmp/go.tar.gz https://go.dev/dl/go1.24.4.linux-${GO_ARCH}.tar.gz && \
rm -rf /usr/local/go && \
tar -C /usr/local -xzf /tmp/go.tar.gz && \
rm /tmp/go.tar.gz
ENV PATH="/usr/local/go/bin:${PATH}"
ENV GOPATH="/root/go"
ENV PATH="${GOPATH}/bin:${PATH}"
# Create virtual framebuffer on startup
RUN echo '#!/bin/bash\n\
Xvfb :99 -screen 0 1920x1080x24 &\n\
export DISPLAY=:99\n\
sleep 2\n\
x11vnc -display :99 -forever -nopw -quiet -shared -bg\n\
' > /usr/local/bin/start-vnc.sh && chmod +x /usr/local/bin/start-vnc.sh
# Copy hdmistat source
COPY . /hdmistat
WORKDIR /hdmistat
# Build hdmistat
RUN make build && \
cp hdmistat /usr/local/bin/
# Create test script
RUN echo '#!/bin/bash\n\
echo "Starting virtual framebuffer..."\n\
/usr/local/bin/start-vnc.sh\n\
echo "VNC server started on port 5900"\n\
echo ""\n\
echo "Creating virtual framebuffer device..."\n\
# Note: In Docker, we cannot access real /dev/fb0\n\
# For testing, hdmistat can be modified to render to a file/image\n\
echo ""\n\
echo "Starting hdmistat in test mode..."\n\
hdmistat info\n\
echo ""\n\
echo "To test hdmistat daemon:"\n\
echo " hdmistat daemon --framebuffer /tmp/fb0"\n\
echo ""\n\
echo "Note: Real framebuffer access requires --privileged mode"\n\
exec /bin/bash\n\
' > /test-hdmistat.sh && chmod +x /test-hdmistat.sh
# Expose VNC port
EXPOSE 5900
CMD ["/test-hdmistat.sh"]
EOF
}
# Create docker-compose file
create_compose() {
log "Creating docker-compose configuration..."
cat > "${PROJECT_DIR}/docker-compose.fbtest.yml" << 'EOF'
version: '3.8'
services:
hdmistat-test:
build:
context: .
dockerfile: Dockerfile.fbtest
image: hdmistat-fbtest:latest
container_name: hdmistat-fbtest
# Uncomment for real framebuffer access (Linux only)
# privileged: true
# devices:
# - /dev/fb0:/dev/fb0
ports:
- "5900:5900" # VNC port
volumes:
- .:/hdmistat:rw
- /tmp/.X11-unix:/tmp/.X11-unix:rw
environment:
- DISPLAY=:99
stdin_open: true
tty: true
# Uncomment for systemd support
# command: /sbin/init
# tmpfs:
# - /run
# - /run/lock
# - /tmp
# volumes:
# - /sys/fs/cgroup:/sys/fs/cgroup:ro
EOF
}
# Create run script
create_run_script() {
log "Creating run script..."
cat > "${PROJECT_DIR}/run-docker-fbtest.sh" << 'EOF'
#!/bin/bash
echo "Building and starting hdmistat framebuffer test container..."
echo ""
# Build the container
docker-compose -f docker-compose.fbtest.yml build
# Run the container
echo "Starting container..."
echo "VNC will be available on localhost:5900"
echo ""
docker-compose -f docker-compose.fbtest.yml run --rm hdmistat-test
# Cleanup
echo ""
echo "Cleaning up..."
docker-compose -f docker-compose.fbtest.yml down
EOF
chmod +x "${PROJECT_DIR}/run-docker-fbtest.sh"
}
# Create VNC helper
create_vnc_helper() {
cat > "${PROJECT_DIR}/view-docker-vnc.sh" << 'EOF'
#!/bin/bash
echo "Connecting to VNC server in Docker container..."
echo "Make sure the container is running first!"
echo ""
# Try different VNC viewers
if command -v vncviewer &> /dev/null; then
vncviewer localhost:5900
elif command -v open &> /dev/null; then
open vnc://localhost:5900
else
echo "No VNC viewer found"
echo "Connect manually to: localhost:5900"
fi
EOF
chmod +x "${PROJECT_DIR}/view-docker-vnc.sh"
}
# Print instructions
print_instructions() {
echo ""
echo "========================================"
echo "Docker Framebuffer Test Setup Complete!"
echo "========================================"
echo ""
echo "Three test environments have been created:"
echo ""
echo "1. QEMU Raspberry Pi (Full emulation):"
echo " ./scripts/qemu-rpi-test.sh"
echo ""
echo "2. QEMU with Alpine (Lightweight):"
echo " ./scripts/qemu-fb-test.sh"
echo ""
echo "3. Docker with virtual framebuffer:"
echo " ./run-docker-fbtest.sh"
echo ""
echo "For Docker testing:"
echo " - Easiest to set up"
echo " - Uses virtual framebuffer (Xvfb)"
echo " - View via VNC on port 5900"
echo ""
echo "For QEMU testing:"
echo " - More realistic framebuffer"
echo " - Full system emulation"
echo " - Better for final testing"
echo ""
warn "Note: Real framebuffer (/dev/fb0) testing requires:"
warn " - Linux host system"
warn " - Running with appropriate permissions"
warn " - Or using QEMU/real hardware"
}
# Main
main() {
log "Setting up Docker framebuffer test environment..."
create_dockerfile
create_compose
create_run_script
create_vnc_helper
print_instructions
log "Setup complete!"
}
main "$@"

258
scripts/qemu-alpine-arm64.sh Executable file
View File

@ -0,0 +1,258 @@
#!/bin/bash
set -euo pipefail
# Simple ARM64 Alpine Linux QEMU setup for hdmistat testing
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
WORK_DIR="${PROJECT_DIR}/qemu-alpine-test"
# QEMU settings
QEMU_MEM="2G"
QEMU_CPUS="4"
DISK_SIZE="4G"
VNC_PORT="5902"
# Alpine Linux ARM64
ALPINE_VERSION="3.19"
ALPINE_ISO="alpine-virt-${ALPINE_VERSION}.0-aarch64.iso"
ALPINE_URL="https://dl-cdn.alpinelinux.org/alpine/v${ALPINE_VERSION}/releases/aarch64/${ALPINE_ISO}"
# Colors
GREEN='\033[0;32m'
RED='\033[0;31m'
NC='\033[0m'
log() {
echo -e "${GREEN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $*"
}
error() {
echo -e "${RED}[ERROR]${NC} $*" >&2
exit 1
}
# Check dependencies
check_dependencies() {
log "Checking dependencies..."
local deps=("qemu-system-aarch64" "wget")
for dep in "${deps[@]}"; do
if ! command -v "$dep" &> /dev/null; then
error "$dep not found"
fi
done
log "Dependencies OK"
}
# Setup directories
setup_directories() {
log "Setting up directories..."
mkdir -p "$WORK_DIR"
}
# Download Alpine Linux
download_alpine() {
cd "$WORK_DIR"
if [ -f "$ALPINE_ISO" ]; then
log "Alpine Linux ISO already exists"
return
fi
log "Downloading Alpine Linux ARM64..."
wget -O "$ALPINE_ISO" "$ALPINE_URL" || error "Failed to download Alpine"
}
# Create disk image
create_disk() {
cd "$WORK_DIR"
if [ -f "hdmistat-test.qcow2" ]; then
log "Disk image already exists"
return
fi
log "Creating disk image..."
qemu-img create -f qcow2 hdmistat-test.qcow2 $DISK_SIZE
}
# Create setup script
create_setup_script() {
cat > "${WORK_DIR}/setup-hdmistat.sh" << 'EOF'
#!/bin/sh
set -e
echo "==================================="
echo "hdmistat Alpine Linux Setup Script"
echo "==================================="
# Update packages
echo "Updating packages..."
apk update
# Install required packages
echo "Installing build dependencies..."
apk add --no-cache \
go \
git \
make \
gcc \
musl-dev \
linux-headers \
bash \
sudo \
fbset \
util-linux
# Install newer Go if needed
GO_VERSION="1.24.4"
if ! go version | grep -q "$GO_VERSION"; then
echo "Installing Go $GO_VERSION..."
wget -q -O /tmp/go.tar.gz "https://go.dev/dl/go${GO_VERSION}.linux-aarch64.tar.gz"
rm -rf /usr/local/go
tar -C /usr/local -xzf /tmp/go.tar.gz
rm /tmp/go.tar.gz
export PATH="/usr/local/go/bin:$PATH"
fi
# Build hdmistat from mounted source
if [ -d "/mnt/hdmistat" ]; then
echo "Building hdmistat from source..."
cd /mnt/hdmistat
make build
cp hdmistat /usr/local/bin/
echo "hdmistat installed successfully!"
else
echo "Warning: hdmistat source not mounted at /mnt/hdmistat"
echo "Mount with: mount -t 9p -o trans=virtio,version=9p2000.L hdmistat /mnt/hdmistat"
fi
# Create test config
mkdir -p /etc/hdmistat
cat > /etc/hdmistat/config.yaml << 'CONFIG_EOF'
framebuffer_device: /dev/fb0
rotation_interval: 10s
update_interval: 1s
log_level: info
width: 1024
height: 768
screens:
- overview
- top_cpu
- top_memory
CONFIG_EOF
echo ""
echo "Setup complete!"
echo ""
echo "To test hdmistat:"
echo "1. hdmistat info - Show system information"
echo "2. hdmistat daemon - Run the framebuffer daemon"
echo "3. hdmistat status - Check daemon status"
echo ""
echo "Note: Framebuffer access requires appropriate permissions"
EOF
chmod +x "${WORK_DIR}/setup-hdmistat.sh"
}
# Create run script
create_run_script() {
cat > "${WORK_DIR}/run-qemu.sh" << EOF
#!/bin/bash
cd "$WORK_DIR"
echo "Starting QEMU ARM64 Alpine Linux..."
echo ""
echo "Instructions:"
echo "1. Boot Alpine Linux and login as root (no password)"
echo "2. Mount the hdmistat source: mkdir -p /mnt/hdmistat && mount -t 9p -o trans=virtio,version=9p2000.L hdmistat /mnt/hdmistat"
echo "3. Run the setup script: sh /mnt/hdmistat/scripts/qemu-alpine-test/setup-hdmistat.sh"
echo ""
echo "VNC available on port $VNC_PORT"
echo "Press Ctrl+A, X to quit"
echo ""
# Find EFI firmware
EFI_CODE=""
for path in /nix/store/*/share/qemu/edk2-aarch64-code.fd /run/current-system/sw/share/qemu/edk2-aarch64-code.fd; do
if [ -f "\$path" ]; then
EFI_CODE="\$path"
break
fi
done
BIOS_ARGS=""
if [ -n "\$EFI_CODE" ]; then
BIOS_ARGS="-bios \$EFI_CODE"
fi
qemu-system-aarch64 \\
-M virt \\
-accel hvf \\
-cpu host \\
-smp $QEMU_CPUS \\
-m $QEMU_MEM \\
-drive file=hdmistat-test.qcow2,format=qcow2,if=virtio \\
-cdrom $ALPINE_ISO \\
-boot d \\
-netdev user,id=net0,hostfwd=tcp::2223-:22 \\
-device virtio-net-pci,netdev=net0 \\
-device virtio-gpu-pci \\
-display default,show-cursor=on \\
-vnc :2 \\
-serial mon:stdio \\
-virtfs local,path="${PROJECT_DIR}",mount_tag=hdmistat,security_model=none,id=hdmistat \\
\$BIOS_ARGS \\
\${@}
EOF
chmod +x "${WORK_DIR}/run-qemu.sh"
}
# Create VNC helper
create_vnc_helper() {
cat > "${WORK_DIR}/view-vnc.sh" << EOF
#!/bin/bash
echo "Opening VNC viewer on localhost:$VNC_PORT"
vncviewer localhost:$VNC_PORT || open vnc://localhost:$VNC_PORT || echo "Connect to VNC at localhost:$VNC_PORT"
EOF
chmod +x "${WORK_DIR}/view-vnc.sh"
}
# Print instructions
print_instructions() {
echo ""
echo "======================================="
echo "QEMU ARM64 Alpine Setup Complete!"
echo "======================================="
echo ""
echo "To start testing:"
echo " cd $WORK_DIR"
echo " ./run-qemu.sh"
echo ""
echo "This creates a lightweight Alpine Linux ARM64 VM with:"
echo " - Native ARM64 virtualization (hvf)"
echo " - Framebuffer support"
echo " - hdmistat source mounted via 9p"
echo " - No cloud-init required"
echo ""
}
# Main
main() {
log "Setting up QEMU ARM64 Alpine environment..."
check_dependencies
setup_directories
download_alpine
create_disk
create_setup_script
create_run_script
create_vnc_helper
print_instructions
log "Setup complete!"
}
main "$@"

335
scripts/qemu-fb-test.sh Executable file
View File

@ -0,0 +1,335 @@
#!/bin/bash
set -euo pipefail
# ARM-native QEMU test with framebuffer using Alpine Linux (virtualization, not emulation)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
WORK_DIR="${PROJECT_DIR}/qemu-fb-test"
CLOUD_INIT_DIR="${WORK_DIR}/cloud-init"
# QEMU settings
QEMU_MEM="2G"
QEMU_CPUS="4"
DISK_SIZE="4G"
VNC_PORT="5902"
# Alpine Linux ARM64 (native virtualization on Apple Silicon)
ALPINE_VERSION="3.19"
ALPINE_ISO="alpine-virt-${ALPINE_VERSION}.0-aarch64.iso"
ALPINE_URL="https://dl-cdn.alpinelinux.org/alpine/v${ALPINE_VERSION}/releases/aarch64/${ALPINE_ISO}"
# Colors
GREEN='\033[0;32m'
RED='\033[0;31m'
NC='\033[0m'
log() {
echo -e "${GREEN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $*"
}
error() {
echo -e "${RED}[ERROR]${NC} $*" >&2
exit 1
}
# Check dependencies
check_dependencies() {
log "Checking dependencies..."
if ! command -v qemu-system-aarch64 &> /dev/null; then
error "qemu-system-aarch64 not found"
fi
log "Dependencies OK"
}
# Setup directories
setup_directories() {
log "Setting up directories..."
mkdir -p "$WORK_DIR"
mkdir -p "$CLOUD_INIT_DIR"
}
# Download Alpine Linux
download_alpine() {
cd "$WORK_DIR"
if [ -f "$ALPINE_ISO" ]; then
log "Alpine Linux ISO already exists"
return
fi
log "Downloading Alpine Linux..."
wget -O "$ALPINE_ISO" "$ALPINE_URL" || error "Failed to download Alpine"
}
# Create disk image
create_disk() {
cd "$WORK_DIR"
if [ -f "hdmistat-test.qcow2" ]; then
log "Disk image already exists"
return
fi
log "Creating disk image..."
qemu-img create -f qcow2 hdmistat-test.qcow2 $DISK_SIZE
}
# Create startup script that will be run inside the VM
create_vm_setup_script() {
cat > "${WORK_DIR}/setup-hdmistat.sh" << 'EOF'
#!/bin/sh
set -e
echo "Setting up hdmistat test environment..."
# Install required packages
apk update
apk add --no-cache \
go \
git \
make \
gcc \
musl-dev \
linux-headers \
bash \
sudo \
openrc \
fbset \
util-linux
# Enable framebuffer console
rc-update add consolefont boot
rc-update add keymaps boot
# Create test user
adduser -D -s /bin/bash testuser
echo "testuser:hdmistat" | chpasswd
addgroup testuser wheel
echo "%wheel ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/wheel
# Mount shared folder if available
if [ -e /dev/vdb ]; then
mkdir -p /mnt/hdmistat
mount /dev/vdb /mnt/hdmistat
fi
# Build hdmistat
if [ -d "/mnt/hdmistat" ]; then
echo "Building hdmistat from mounted source..."
cd /mnt/hdmistat
make build
cp hdmistat /usr/local/bin/
else
echo "Installing hdmistat from git..."
export GOPROXY=https://proxy.golang.org
go install git.eeqj.de/sneak/hdmistat/cmd/hdmistat@latest
cp ~/go/bin/hdmistat /usr/local/bin/
fi
# Create init script for hdmistat
cat > /etc/init.d/hdmistat << 'INIT_EOF'
#!/sbin/openrc-run
name="hdmistat"
description="HDMI Statistics Display Daemon"
command="/usr/local/bin/hdmistat"
command_args="daemon"
command_background=true
pidfile="/run/${RC_SVCNAME}.pid"
start_stop_daemon_args="--stdout /var/log/hdmistat.log --stderr /var/log/hdmistat.log"
depend() {
need localmount
after bootmisc
}
INIT_EOF
chmod +x /etc/init.d/hdmistat
# Create config directory
mkdir -p /etc/hdmistat
cat > /etc/hdmistat/config.yaml << 'CONFIG_EOF'
framebuffer_device: /dev/fb0
rotation_interval: 10s
update_interval: 1s
log_level: info
width: 1024
height: 768
screens:
- overview
- top_cpu
- top_memory
CONFIG_EOF
# Enable services
rc-update add hdmistat default
echo "Setup complete! You can now:"
echo "1. Switch to framebuffer console with Alt+F1"
echo "2. Check hdmistat status: rc-service hdmistat status"
echo "3. View logs: tail -f /var/log/hdmistat.log"
EOF
chmod +x "${WORK_DIR}/setup-hdmistat.sh"
}
# Create run script
create_run_script() {
cat > "${WORK_DIR}/run-qemu.sh" << EOF
#!/bin/bash
cd "$WORK_DIR"
echo "Starting QEMU with framebuffer support..."
echo ""
echo "After boot:"
echo "1. Login as root (no password)"
echo "2. Run: /mnt/setup-hdmistat.sh"
echo "3. Switch to framebuffer with Alt+F1 to see hdmistat"
echo "4. VNC available on port $VNC_PORT"
echo ""
echo "Press Ctrl+A, X to quit"
echo ""
# Create a FAT filesystem image with our setup script
if [ ! -f "scripts.img" ]; then
dd if=/dev/zero of=scripts.img bs=1M count=10
mkfs.vfat scripts.img
mcopy -i scripts.img setup-hdmistat.sh ::
fi
# Find QEMU firmware files
EFI_CODE=""
for path in /nix/store/*/share/qemu/edk2-aarch64-code.fd /run/current-system/sw/share/qemu/edk2-aarch64-code.fd; do
if [ -f "$path" ]; then
EFI_CODE="$path"
break
fi
done
if [ -z "$EFI_CODE" ]; then
echo "Warning: EFI firmware not found, trying without it"
BIOS_ARGS=""
else
BIOS_ARGS="-bios $EFI_CODE"
fi
qemu-system-aarch64 \\
-M virt \\
-accel hvf \\
-cpu host \\
-smp $QEMU_CPUS \\
-m $QEMU_MEM \\
-drive file=hdmistat-test.qcow2,format=qcow2,if=virtio \\
-drive file=scripts.img,format=raw,if=virtio \\
-drive file="${PROJECT_DIR}",if=virtio,format=raw,readonly=on \\
-cdrom $ALPINE_ISO \\
-boot d \\
-netdev user,id=net0,hostfwd=tcp::2223-:22 \\
-device virtio-net-pci,netdev=net0 \\
-device virtio-gpu-pci \\
-display default,show-cursor=on \\
-vnc :2 \\
-serial mon:stdio \\
$BIOS_ARGS \\
\${@}
EOF
chmod +x "${WORK_DIR}/run-qemu.sh"
}
# Create helper scripts
create_helpers() {
# VNC viewer
cat > "${WORK_DIR}/view-vnc.sh" << EOF
#!/bin/bash
echo "Opening VNC viewer on localhost:$VNC_PORT"
vncviewer localhost:$VNC_PORT || open vnc://localhost:$VNC_PORT
EOF
chmod +x "${WORK_DIR}/view-vnc.sh"
# Instructions
cat > "${WORK_DIR}/README.md" << EOF
# QEMU Framebuffer Test Environment
This is a lightweight QEMU setup for testing hdmistat with framebuffer support.
## Quick Start
1. Start QEMU:
\`\`\`bash
./run-qemu.sh
\`\`\`
2. In the Alpine Linux boot menu, select the default option
3. Login as root (no password required)
4. Mount the scripts volume:
\`\`\`bash
mkdir -p /mnt
mount /dev/vdb /mnt
\`\`\`
5. Run the setup script:
\`\`\`bash
/mnt/setup-hdmistat.sh
\`\`\`
6. Start hdmistat:
\`\`\`bash
rc-service hdmistat start
\`\`\`
7. View the framebuffer output:
- Press Alt+F1 to switch to the framebuffer console
- Or use VNC: ./view-vnc.sh
## Tips
- The framebuffer is available at /dev/fb0
- Default resolution is 1024x768
- hdmistat source is mounted at /dev/vdc (mount to /mnt/hdmistat)
- Logs are at /var/log/hdmistat.log
## Troubleshooting
If framebuffer doesn't work:
1. Check if /dev/fb0 exists
2. Try: modprobe fbdev
3. Check dmesg for framebuffer messages
EOF
}
# Print instructions
print_instructions() {
echo ""
echo "======================================"
echo "QEMU Framebuffer Test Setup Complete!"
echo "======================================"
echo ""
echo "To start testing:"
echo " cd $WORK_DIR"
echo " ./run-qemu.sh"
echo ""
echo "See README.md for detailed instructions"
echo ""
}
# Main
main() {
log "Setting up QEMU framebuffer test environment..."
check_dependencies
setup_directories
download_alpine
create_disk
create_vm_setup_script
create_run_script
create_helpers
print_instructions
log "Setup complete!"
}
main "$@"

338
scripts/qemu-rpi-test.sh Executable file
View File

@ -0,0 +1,338 @@
#!/bin/bash
set -euo pipefail
# Script to run Raspberry Pi OS in QEMU with hdmistat installation
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
WORK_DIR="${PROJECT_DIR}/qemu-test"
CLOUD_INIT_DIR="${WORK_DIR}/cloud-init"
# QEMU and image settings
QEMU_ARCH="aarch64"
RPI_IMAGE_URL="https://downloads.raspberrypi.org/raspios_lite_arm64/images/raspios_lite_arm64-2024-03-15/2024-03-15-raspios-bookworm-arm64-lite.img.xz"
RPI_IMAGE_NAME="raspios-lite-arm64.img"
QEMU_MEM="2G"
QEMU_CPUS="4"
VNC_PORT="5901"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m' # No Color
log() {
echo -e "${GREEN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $*"
}
error() {
echo -e "${RED}[ERROR]${NC} $*" >&2
exit 1
}
warn() {
echo -e "${YELLOW}[WARN]${NC} $*"
}
# Check dependencies
check_dependencies() {
log "Checking dependencies..."
local deps=("qemu-system-aarch64" "cloud-localds" "xz" "wget")
local missing=()
for dep in "${deps[@]}"; do
if ! command -v "$dep" &> /dev/null; then
missing+=("$dep")
fi
done
if [ ${#missing[@]} -ne 0 ]; then
error "Missing dependencies: ${missing[*]}"
fi
log "All dependencies found"
}
# Create work directory
setup_directories() {
log "Setting up directories..."
mkdir -p "$WORK_DIR"
mkdir -p "$CLOUD_INIT_DIR"
}
# Download Raspberry Pi OS image
download_image() {
if [ -f "${WORK_DIR}/${RPI_IMAGE_NAME}" ]; then
log "Raspberry Pi OS image already exists"
return
fi
log "Downloading Raspberry Pi OS image..."
cd "$WORK_DIR"
if [ ! -f "${RPI_IMAGE_NAME}.xz" ]; then
wget -O "${RPI_IMAGE_NAME}.xz" "$RPI_IMAGE_URL" || error "Failed to download image"
fi
log "Extracting image..."
xz -d -k "${RPI_IMAGE_NAME}.xz" || error "Failed to extract image"
# Resize image to have more space
log "Resizing image to 8GB..."
qemu-img resize "${RPI_IMAGE_NAME}" 8G
}
# Create cloud-init configuration
create_cloud_init() {
log "Creating cloud-init configuration..."
# Create meta-data
cat > "${CLOUD_INIT_DIR}/meta-data" << 'EOF'
instance-id: hdmistat-test-01
local-hostname: hdmistat-test
EOF
# Create user-data with hdmistat installation
cat > "${CLOUD_INIT_DIR}/user-data" << 'EOF'
#cloud-config
hostname: hdmistat-test
manage_etc_hosts: true
users:
- name: pi
groups: [adm, audio, cdrom, dialout, dip, floppy, lxd, netdev, plugdev, sudo, video]
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
lock_passwd: false
passwd: $6$rounds=4096$8VNRmFT9yR$7TVlOepTJjMW0CzBkpMrdWA7aH4rZ94pZng8XjqaY8d8qqBmFvO/hYL5L2gqJ5nKLhDR8Jz9Z9nqfHBl5kVZHe1
ssh_authorized_keys:
- ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDf0q4PyG0doiBQYV7OlOxbRjle026hJPBWbZe test@hdmistat
packages:
- git
- golang
- build-essential
- systemd
- htop
- tmux
# Enable SSH
ssh_pwauth: true
# Update system
package_update: true
package_upgrade: true
# Install Go 1.24.4
write_files:
- path: /tmp/install-go.sh
permissions: '0755'
content: |
#!/bin/bash
set -e
GO_VERSION="1.24.4"
wget -q -O /tmp/go.tar.gz "https://go.dev/dl/go${GO_VERSION}.linux-arm64.tar.gz"
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf /tmp/go.tar.gz
echo 'export PATH=$PATH:/usr/local/go/bin' | sudo tee -a /etc/profile
echo 'export GOPATH=$HOME/go' | sudo tee -a /etc/profile
echo 'export PATH=$PATH:$GOPATH/bin' | sudo tee -a /etc/profile
- path: /tmp/build-hdmistat.sh
permissions: '0755'
content: |
#!/bin/bash
set -e
export PATH=$PATH:/usr/local/go/bin
export GOPATH=/home/pi/go
export PATH=$PATH:$GOPATH/bin
# Clone and build hdmistat from local source
mkdir -p /home/pi/hdmistat-src
cd /home/pi/hdmistat-src
# Note: In real usage, you'd clone from git repo
# For testing, we'll mount the source directory
# Build hdmistat
if [ -d "/mnt/hdmistat" ]; then
cd /mnt/hdmistat
make build
sudo cp hdmistat /usr/local/bin/
else
# Fallback: try to install from git
go install git.eeqj.de/sneak/hdmistat/cmd/hdmistat@latest
sudo cp $GOPATH/bin/hdmistat /usr/local/bin/
fi
# Install as systemd service
sudo /usr/local/bin/hdmistat install
# Enable framebuffer console
sudo sed -i 's/console=serial0,115200 //' /boot/cmdline.txt
echo "hdmi_force_hotplug=1" | sudo tee -a /boot/config.txt
echo "hdmi_group=2" | sudo tee -a /boot/config.txt
echo "hdmi_mode=82" | sudo tee -a /boot/config.txt
echo "framebuffer_width=1920" | sudo tee -a /boot/config.txt
echo "framebuffer_height=1080" | sudo tee -a /boot/config.txt
# Run installation scripts
runcmd:
- /tmp/install-go.sh
- sleep 5
- /tmp/build-hdmistat.sh
- sudo systemctl daemon-reload
- sudo systemctl enable hdmistat
- sudo systemctl start hdmistat
# Final message
final_message: "hdmistat installation complete! System ready for testing."
EOF
# Create cloud-init ISO
log "Creating cloud-init ISO..."
cd "$WORK_DIR"
cloud-localds cloud-init.iso "${CLOUD_INIT_DIR}/user-data" "${CLOUD_INIT_DIR}/meta-data"
}
# Create QEMU run script
create_run_script() {
log "Creating QEMU run script..."
cat > "${WORK_DIR}/run-qemu.sh" << EOF
#!/bin/bash
# Run QEMU with Raspberry Pi emulation
cd "$WORK_DIR"
echo "Starting QEMU Raspberry Pi emulation..."
echo "VNC server will be available on port $VNC_PORT"
echo "SSH: ssh pi@localhost -p 2222 (password: raspberry)"
echo "Framebuffer: The emulated display supports framebuffer at /dev/fb0"
echo ""
echo "Press Ctrl+A, X to quit QEMU"
qemu-system-aarch64 \\
-M raspi3b \\
-cpu cortex-a72 \\
-smp $QEMU_CPUS \\
-m $QEMU_MEM \\
-kernel kernel8.img \\
-dtb bcm2710-rpi-3-b-plus.dtb \\
-append "rw earlyprintk loglevel=8 console=ttyAMA0,115200 dwc_otg.lpm_enable=0 root=/dev/mmcblk0p2 rootdelay=1" \\
-drive file=${RPI_IMAGE_NAME},format=raw,if=sd \\
-drive file=cloud-init.iso,format=raw,if=none,id=cloud-init \\
-device usb-storage,drive=cloud-init \\
-netdev user,id=net0,hostfwd=tcp::2222-:22 \\
-device usb-net,netdev=net0 \\
-vnc :1 \\
-serial stdio \\
-display none \\
-virtfs local,path="${PROJECT_DIR}",mount_tag=hdmistat,security_model=passthrough,id=hdmistat \\
\${@}
EOF
chmod +x "${WORK_DIR}/run-qemu.sh"
}
# Download kernel and DTB files
download_kernel() {
log "Downloading kernel and DTB files..."
cd "$WORK_DIR"
# These are required for booting Raspberry Pi OS in QEMU
if [ ! -f "kernel8.img" ]; then
wget -q https://github.com/dhruvvyas90/qemu-rpi-kernel/raw/master/kernel8.img || \
error "Failed to download kernel"
fi
if [ ! -f "bcm2710-rpi-3-b-plus.dtb" ]; then
wget -q https://github.com/dhruvvyas90/qemu-rpi-kernel/raw/master/bcm2710-rpi-3-b-plus.dtb || \
error "Failed to download DTB"
fi
}
# Create helper scripts
create_helpers() {
log "Creating helper scripts..."
# VNC viewer script
cat > "${WORK_DIR}/view-vnc.sh" << EOF
#!/bin/bash
echo "Opening VNC viewer on localhost:$VNC_PORT"
vncviewer localhost:$VNC_PORT || echo "VNC viewer not found. Connect manually to localhost:$VNC_PORT"
EOF
chmod +x "${WORK_DIR}/view-vnc.sh"
# SSH script
cat > "${WORK_DIR}/ssh-pi.sh" << EOF
#!/bin/bash
echo "Connecting to Raspberry Pi via SSH..."
echo "Default password is: raspberry"
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null pi@localhost -p 2222
EOF
chmod +x "${WORK_DIR}/ssh-pi.sh"
# Mount script for 9p filesystem
cat > "${WORK_DIR}/mount-hdmistat.sh" << EOF
#!/bin/bash
# Run this inside the VM to mount the hdmistat source
echo "Run this inside the Raspberry Pi VM:"
echo "sudo mkdir -p /mnt/hdmistat"
echo "sudo mount -t 9p -o trans=virtio hdmistat /mnt/hdmistat -oversion=9p2000.L"
EOF
chmod +x "${WORK_DIR}/mount-hdmistat.sh"
}
# Print usage instructions
print_instructions() {
log "Setup complete!"
echo ""
echo "========================================="
echo "QEMU Raspberry Pi Test Environment Ready"
echo "========================================="
echo ""
echo "To start the emulated Raspberry Pi:"
echo " cd $WORK_DIR"
echo " ./run-qemu.sh"
echo ""
echo "To view the display (framebuffer):"
echo " ./view-vnc.sh"
echo ""
echo "To SSH into the system:"
echo " ./ssh-pi.sh"
echo ""
echo "To mount hdmistat source inside VM:"
echo " 1. SSH into the VM"
echo " 2. Run: sudo mount -t 9p -o trans=virtio hdmistat /mnt/hdmistat -oversion=9p2000.L"
echo ""
echo "Default credentials:"
echo " Username: pi"
echo " Password: raspberry"
echo ""
echo "The framebuffer device will be available at /dev/fb0"
echo "hdmistat will be installed automatically via cloud-init on first boot"
echo ""
}
# Main execution
main() {
log "Starting QEMU Raspberry Pi setup for hdmistat testing..."
check_dependencies
setup_directories
download_image
download_kernel
create_cloud_init
create_run_script
create_helpers
print_instructions
log "Setup completed successfully!"
}
# Run main function
main "$@"

315
scripts/qemu-ubuntu-arm64.sh Executable file
View File

@ -0,0 +1,315 @@
#!/bin/bash
set -euo pipefail
# ARM64 Ubuntu QEMU setup with cloud-init for hdmistat testing
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
WORK_DIR="${PROJECT_DIR}/qemu-ubuntu-test"
CLOUD_INIT_DIR="${WORK_DIR}/cloud-init"
# QEMU settings
QEMU_MEM="2G"
QEMU_CPUS="4"
DISK_SIZE="8G"
VNC_PORT="5902"
# Ubuntu Cloud Image ARM64
UBUNTU_VERSION="22.04"
UBUNTU_IMAGE="ubuntu-${UBUNTU_VERSION}-server-cloudimg-arm64.img"
UBUNTU_URL="https://cloud-images.ubuntu.com/releases/${UBUNTU_VERSION}/release/${UBUNTU_IMAGE}"
# Colors
GREEN='\033[0;32m'
RED='\033[0;31m'
NC='\033[0m'
log() {
echo -e "${GREEN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $*"
}
error() {
echo -e "${RED}[ERROR]${NC} $*" >&2
exit 1
}
# Check dependencies
check_dependencies() {
log "Checking dependencies..."
local deps=("qemu-system-aarch64" "qemu-img" "wget" "genisoimage")
for dep in "${deps[@]}"; do
if ! command -v "$dep" &> /dev/null; then
error "$dep not found"
fi
done
log "Dependencies OK"
}
# Setup directories
setup_directories() {
log "Setting up directories..."
mkdir -p "$WORK_DIR"
mkdir -p "$CLOUD_INIT_DIR"
}
# Download Ubuntu cloud image
download_ubuntu() {
cd "$WORK_DIR"
if [ -f "$UBUNTU_IMAGE" ]; then
log "Ubuntu cloud image already exists"
return
fi
log "Downloading Ubuntu ${UBUNTU_VERSION} ARM64 cloud image..."
wget -O "$UBUNTU_IMAGE" "$UBUNTU_URL" || error "Failed to download Ubuntu image"
}
# Create working disk from cloud image
create_disk() {
cd "$WORK_DIR"
if [ -f "hdmistat-test.qcow2" ]; then
log "Working disk already exists"
return
fi
log "Creating working disk from cloud image..."
qemu-img create -f qcow2 -F qcow2 -b "$UBUNTU_IMAGE" hdmistat-test.qcow2 $DISK_SIZE
}
# Create cloud-init configuration
create_cloud_init() {
log "Creating cloud-init configuration..."
# Create meta-data
cat > "${CLOUD_INIT_DIR}/meta-data" << 'EOF'
instance-id: hdmistat-test-01
local-hostname: hdmistat-test
EOF
# Create user-data with hdmistat installation
cat > "${CLOUD_INIT_DIR}/user-data" << 'EOF'
#cloud-config
hostname: hdmistat-test
manage_etc_hosts: true
users:
- name: ubuntu
groups: [adm, audio, cdrom, dialout, dip, floppy, lxd, netdev, plugdev, sudo, video]
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
lock_passwd: false
passwd: $6$rounds=4096$8VNRmFT9yR$7TVlOepTJjMW0CzBkpMrdWA7aH4rZ94pZng8XjqaY8d8qqBmFvO/hYL5L2gqJ5nKLhDR8Jz9Z9nqfHBl5kVZHe1
packages:
- git
- build-essential
- wget
- fbset
# Enable SSH (already enabled in cloud images)
ssh_pwauth: true
# Update system
package_update: true
package_upgrade: false
write_files:
- path: /tmp/install-go.sh
permissions: '0755'
content: |
#!/bin/bash
set -e
GO_VERSION="1.24.4"
wget -q -O /tmp/go.tar.gz "https://go.dev/dl/go${GO_VERSION}.linux-arm64.tar.gz"
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf /tmp/go.tar.gz
echo 'export PATH=$PATH:/usr/local/go/bin' | sudo tee -a /etc/profile
echo 'export GOPATH=$HOME/go' | sudo tee -a /etc/profile
echo 'export PATH=$PATH:$GOPATH/bin' | sudo tee -a /etc/profile
- path: /tmp/setup-hdmistat.sh
permissions: '0755'
content: |
#!/bin/bash
set -e
export PATH=$PATH:/usr/local/go/bin
export GOPATH=$HOME/go
export PATH=$PATH:$GOPATH/bin
# Mount hdmistat source
sudo mkdir -p /mnt/hdmistat
sudo mount -t 9p -o trans=virtio,version=9p2000.L hdmistat /mnt/hdmistat || echo "Failed to mount hdmistat source"
# Build hdmistat
if [ -d "/mnt/hdmistat" ]; then
echo "Building hdmistat from mounted source..."
cd /mnt/hdmistat
make build
sudo cp hdmistat /usr/local/bin/
fi
# Create config
sudo mkdir -p /etc/hdmistat
sudo tee /etc/hdmistat/config.yaml << 'CONFIG_EOF'
framebuffer_device: /dev/fb0
rotation_interval: 10s
update_interval: 1s
log_level: info
width: 1024
height: 768
screens:
- overview
- top_cpu
- top_memory
CONFIG_EOF
# Enable framebuffer
echo "fbcon=map:10" | sudo tee -a /etc/default/grub
sudo update-grub || true
runcmd:
- /tmp/install-go.sh
- su - ubuntu -c /tmp/setup-hdmistat.sh
- echo "hdmistat setup complete! Use 'hdmistat daemon' to start"
final_message: "hdmistat test environment ready!"
EOF
# Create cloud-init ISO
log "Creating cloud-init ISO..."
cd "$WORK_DIR"
genisoimage -output cloud-init.iso -volid cidata -joliet -rock "${CLOUD_INIT_DIR}/user-data" "${CLOUD_INIT_DIR}/meta-data"
}
# Create run script
create_run_script() {
cat > "${WORK_DIR}/run-qemu.sh" << EOF
#!/bin/bash
cd "$WORK_DIR"
echo "Starting QEMU ARM64 Ubuntu with cloud-init..."
echo ""
echo "Login: ubuntu / ubuntu"
echo "SSH available on port 2223: ssh ubuntu@localhost -p 2223"
echo "VNC available on port $VNC_PORT"
echo ""
echo "hdmistat will be installed automatically via cloud-init"
echo "Check cloud-init status with: cloud-init status"
echo ""
echo "Press Ctrl+A, X to quit"
echo ""
# Find EFI firmware and vars
EFI_CODE=""
EFI_VARS=""
for dir in /nix/store/*/share/qemu /run/current-system/sw/share/qemu; do
if [ -f "\$dir/edk2-aarch64-code.fd" ]; then
EFI_CODE="\$dir/edk2-aarch64-code.fd"
if [ -f "\$dir/edk2-arm-vars.fd" ]; then
EFI_VARS="\$dir/edk2-arm-vars.fd"
fi
break
fi
done
# Create a copy of EFI vars if found
if [ -n "\$EFI_VARS" ] && [ ! -f "efivars.fd" ]; then
cp "\$EFI_VARS" efivars.fd
fi
BIOS_ARGS=""
if [ -n "\$EFI_CODE" ]; then
BIOS_ARGS="-bios \$EFI_CODE"
if [ -f "efivars.fd" ]; then
BIOS_ARGS="\$BIOS_ARGS -drive if=pflash,format=raw,file=efivars.fd"
fi
fi
qemu-system-aarch64 \\
-M virt \\
-accel hvf \\
-cpu host \\
-smp $QEMU_CPUS \\
-m $QEMU_MEM \\
-nographic \\
-drive file=hdmistat-test.qcow2,format=qcow2,if=virtio \\
-drive file=cloud-init.iso,format=raw,if=virtio \\
-netdev user,id=net0,hostfwd=tcp::2223-:22 \\
-device virtio-net-pci,netdev=net0 \\
-device virtio-gpu-pci \\
-display default,show-cursor=on \\
-vnc :2 \\
-serial mon:stdio \\
-virtfs local,path="${PROJECT_DIR}",mount_tag=hdmistat,security_model=none,id=hdmistat \\
\$BIOS_ARGS \\
\${@}
EOF
chmod +x "${WORK_DIR}/run-qemu.sh"
}
# Create helper scripts
create_helpers() {
# VNC viewer
cat > "${WORK_DIR}/view-vnc.sh" << EOF
#!/bin/bash
echo "Opening VNC viewer on localhost:$VNC_PORT"
vncviewer localhost:$VNC_PORT || open vnc://localhost:$VNC_PORT || echo "Connect to VNC at localhost:$VNC_PORT"
EOF
chmod +x "${WORK_DIR}/view-vnc.sh"
# SSH helper
cat > "${WORK_DIR}/ssh-ubuntu.sh" << EOF
#!/bin/bash
echo "Connecting to Ubuntu VM..."
echo "Password: ubuntu"
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ubuntu@localhost -p 2223
EOF
chmod +x "${WORK_DIR}/ssh-ubuntu.sh"
}
# Print instructions
print_instructions() {
echo ""
echo "======================================="
echo "QEMU ARM64 Ubuntu Setup Complete!"
echo "======================================="
echo ""
echo "To start testing:"
echo " cd $WORK_DIR"
echo " ./run-qemu.sh"
echo ""
echo "Features:"
echo " - Ubuntu ${UBUNTU_VERSION} ARM64 with cloud-init"
echo " - Native ARM64 virtualization (hvf)"
echo " - Automatic hdmistat installation"
echo " - Framebuffer support"
echo " - SSH access on port 2223"
echo " - VNC access on port $VNC_PORT"
echo ""
echo "The cloud-init config is in: $CLOUD_INIT_DIR/"
echo "You can modify it and regenerate the ISO with:"
echo " genisoimage -output cloud-init.iso -volid cidata -joliet -rock user-data meta-data"
echo ""
}
# Main
main() {
log "Setting up QEMU ARM64 Ubuntu environment with cloud-init..."
check_dependencies
setup_directories
download_ubuntu
create_disk
create_cloud_init
create_run_script
create_helpers
print_instructions
log "Setup complete!"
}
main "$@"

Binary file not shown.

View File

@ -0,0 +1,2 @@
instance-id: hdmistat-test-01
local-hostname: hdmistat-test

View File

@ -0,0 +1,101 @@
#cloud-config
hostname: hdmistat-test
manage_etc_hosts: true
users:
- name: ubuntu
groups: [adm, audio, cdrom, dialout, dip, floppy, lxd, netdev, plugdev, sudo, video]
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
lock_passwd: false
plain_text_passwd: ubuntu
packages:
- git
- build-essential
- wget
- fbset
- rsync
# Enable SSH (already enabled in cloud images)
ssh_pwauth: true
# Disable all package updates
package_update: false
package_upgrade: false
package_reboot_if_required: false
write_files:
- path: /tmp/install-go.sh
permissions: '0755'
content: |
#!/bin/bash
set -e
GO_VERSION="1.24.4"
wget -q -O /tmp/go.tar.gz "https://go.dev/dl/go${GO_VERSION}.linux-arm64.tar.gz"
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf /tmp/go.tar.gz
echo 'export PATH=$PATH:/usr/local/go/bin' | sudo tee -a /etc/profile
echo 'export GOPATH=$HOME/go' | sudo tee -a /etc/profile
echo 'export PATH=$PATH:$GOPATH/bin' | sudo tee -a /etc/profile
- path: /tmp/setup-hdmistat.sh
permissions: '0755'
content: |
#!/bin/bash
set -e
export HOME=/root
export PATH=$PATH:/usr/local/go/bin
export GOPATH=$HOME/go
export PATH=$PATH:$GOPATH/bin
export GOCACHE=$HOME/.cache/go-build
mkdir -p $GOCACHE
# Mount hdmistat source
mkdir -p /mnt/hdmistat
mount -t 9p -o trans=virtio,version=9p2000.L hdmistat /mnt/hdmistat || echo "Failed to mount hdmistat source"
# Build hdmistat
if [ -d "/mnt/hdmistat" ]; then
echo "Copying hdmistat source to /tmp..."
rsync -av --exclude='test' --exclude='.git' /mnt/hdmistat/ /tmp/hdmistat-build/
cd /tmp/hdmistat-build
echo "Building hdmistat debug build in /tmp..."
# Build debug version with VCS disabled
make debug GOFLAGS="-buildvcs=false"
cp hdmistat-debug /usr/local/bin/hdmistat
chmod +x /usr/local/bin/hdmistat
echo "hdmistat binary installed to /usr/local/bin/"
fi
# Install hdmistat as systemd service (handles everything)
if [ -f /usr/local/bin/hdmistat ]; then
/usr/local/bin/hdmistat install
# Check service status
systemctl status hdmistat --no-pager || true
# Show logs
journalctl -u hdmistat -n 20 --no-pager || true
else
echo "ERROR: hdmistat binary not found!"
fi
# Enable framebuffer
echo "fbcon=map:10" | tee -a /etc/default/grub
update-grub || true
runcmd:
- /tmp/install-go.sh
- /tmp/setup-hdmistat.sh
- echo "hdmistat setup complete! Service installed and started by 'hdmistat install'"
- sleep 5
- echo "Checking hdmistat service status..."
- systemctl status hdmistat --no-pager || true
- echo "Recent hdmistat logs:"
- journalctl -u hdmistat -n 50 --no-pager || true
- echo "Checking framebuffer device..."
- ls -la /dev/fb* || true
- fbset -i || true
final_message: "hdmistat test environment ready!"

Binary file not shown.

View File

@ -0,0 +1,72 @@
#!/bin/bash
cd "/Users/user/dev/hdmistat/qemu-ubuntu-test"
echo "Starting QEMU ARM64 Ubuntu with cloud-init..."
echo ""
echo "Login: ubuntu / ubuntu"
echo "SSH available on port 2223: ssh ubuntu@localhost -p 2223"
echo "VNC available on port 5902 (password: vncvnc)"
echo ""
echo "hdmistat will be installed automatically via cloud-init"
echo "Check cloud-init status with: cloud-init status"
echo ""
echo "Press Ctrl+A, X to quit"
echo ""
# Create fresh disk image from base image
echo "Creating fresh disk image..."
qemu-img create -f qcow2 -F qcow2 -b ubuntu-22.04-server-cloudimg-arm64.img hdmistat-test.qcow2 8G
# Find EFI firmware and vars
EFI_CODE=""
EFI_VARS=""
for dir in /nix/store/*/share/qemu /run/current-system/sw/share/qemu; do
if [ -f "$dir/edk2-aarch64-code.fd" ]; then
EFI_CODE="$dir/edk2-aarch64-code.fd"
if [ -f "$dir/edk2-arm-vars.fd" ]; then
EFI_VARS="$dir/edk2-arm-vars.fd"
fi
break
fi
done
# Create a copy of EFI vars if found
if [ -n "$EFI_VARS" ] && [ ! -f "efivars.fd" ]; then
cp "$EFI_VARS" efivars.fd
chmod u+rw efivars.fd
fi
BIOS_ARGS=""
if [ -n "$EFI_CODE" ]; then
# Use pflash method for UEFI boot (not -bios)
BIOS_ARGS="-drive if=pflash,file=$EFI_CODE,format=raw,readonly=on"
if [ -f "efivars.fd" ]; then
BIOS_ARGS="$BIOS_ARGS -drive if=pflash,format=raw,file=efivars.fd"
fi
fi
qemu-system-aarch64 \
-M virt \
-accel hvf \
-cpu host \
-smp 4 \
-m 2G \
-nographic \
-object secret,id=vncsec0,data=vncvnc \
-drive file=hdmistat-test.qcow2,format=qcow2,if=virtio \
-drive file=cloud-init.iso,format=raw,if=virtio \
-netdev user,id=net0,hostfwd=tcp::2223-:22 \
-device virtio-net-pci,netdev=net0 \
-device virtio-gpu-pci \
-display default,show-cursor=on \
-vnc :2,password-secret=vncsec0 \
-serial mon:stdio \
-virtfs local,path="/Users/user/dev/hdmistat",mount_tag=hdmistat,security_model=none,id=hdmistat \
$BIOS_ARGS \
${@}
# Clean up disk image after QEMU exits
echo ""
echo "Cleaning up disk image for fresh start next time..."
rm -f hdmistat-test.qcow2
echo "Disk image removed. Next run will start fresh with cloud-init."

View File

@ -0,0 +1,4 @@
#!/bin/bash
echo "Connecting to Ubuntu VM..."
echo "Password: ubuntu"
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ubuntu@localhost -p 2223

View File

@ -0,0 +1,3 @@
#!/bin/bash
echo "Opening VNC viewer on localhost:5902"
vncviewer localhost:5902 || open vnc://localhost:5902 || echo "Connect to VNC at localhost:5902"