checkpointing, heavy dev
This commit is contained in:
parent
a3bc63d2d9
commit
c2040a5c08
7
.gitignore
vendored
7
.gitignore
vendored
@ -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/
|
||||
|
@ -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
|
||||
|
@ -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.
|
25
Makefile
25
Makefile
@ -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)"
|
@ -1,3 +1,4 @@
|
||||
// Package main is the entry point for the hdmistat command-line tool
|
||||
package main
|
||||
|
||||
import (
|
||||
|
4
go.mod
4
go.mod
@ -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
69
go.sum
@ -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=
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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
184
internal/fbdraw/EXAMPLE.md
Normal 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
335
internal/fbdraw/README.md
Normal 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
162
internal/fbdraw/carousel.go
Normal 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
309
internal/fbdraw/display.go
Normal 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
76
internal/fbdraw/doc.go
Normal 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
490
internal/fbdraw/grid.go
Normal 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
|
||||
}
|
160
internal/fbdraw/grid_test.go
Normal file
160
internal/fbdraw/grid_test.go
Normal 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)
|
||||
}
|
||||
}
|
22
internal/fbdraw/interfaces.go
Normal file
22
internal/fbdraw/interfaces.go
Normal 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
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
93
internal/font/fonts/Source_Code_Pro/OFL.txt
Normal file
93
internal/font/fonts/Source_Code_Pro/OFL.txt
Normal 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.
|
79
internal/font/fonts/Source_Code_Pro/README.txt
Normal file
79
internal/font/fonts/Source_Code_Pro/README.txt
Normal 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 aren’t 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.
|
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-Black.ttf
Normal file
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-Black.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-Bold.ttf
Normal file
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-Bold.ttf
Normal file
Binary file not shown.
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-BoldItalic.ttf
Normal file
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-ExtraBold.ttf
Normal file
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-ExtraBold.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-ExtraLight.ttf
Normal file
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-ExtraLight.ttf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-Italic.ttf
Normal file
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-Italic.ttf
Normal file
Binary file not shown.
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-Light.ttf
Normal file
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-Light.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-Medium.ttf
Normal file
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-Medium.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-Regular.ttf
Normal file
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-Regular.ttf
Normal file
Binary file not shown.
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-SemiBold.ttf
Normal file
BIN
internal/font/fonts/Source_Code_Pro/SourceCodePro-SemiBold.ttf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
94
internal/font/fonts/Terminus/OFL.TXT
Normal file
94
internal/font/fonts/Terminus/OFL.TXT
Normal 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.
|
25792
internal/font/fonts/Terminus/ter-u12b.bdf
Normal file
25792
internal/font/fonts/Terminus/ter-u12b.bdf
Normal file
File diff suppressed because it is too large
Load Diff
25792
internal/font/fonts/Terminus/ter-u12n.bdf
Normal file
25792
internal/font/fonts/Terminus/ter-u12n.bdf
Normal file
File diff suppressed because it is too large
Load Diff
28504
internal/font/fonts/Terminus/ter-u14b.bdf
Normal file
28504
internal/font/fonts/Terminus/ter-u14b.bdf
Normal file
File diff suppressed because it is too large
Load Diff
28504
internal/font/fonts/Terminus/ter-u14n.bdf
Normal file
28504
internal/font/fonts/Terminus/ter-u14n.bdf
Normal file
File diff suppressed because it is too large
Load Diff
28504
internal/font/fonts/Terminus/ter-u14v.bdf
Normal file
28504
internal/font/fonts/Terminus/ter-u14v.bdf
Normal file
File diff suppressed because it is too large
Load Diff
31216
internal/font/fonts/Terminus/ter-u16b.bdf
Normal file
31216
internal/font/fonts/Terminus/ter-u16b.bdf
Normal file
File diff suppressed because it is too large
Load Diff
31216
internal/font/fonts/Terminus/ter-u16n.bdf
Normal file
31216
internal/font/fonts/Terminus/ter-u16n.bdf
Normal file
File diff suppressed because it is too large
Load Diff
31216
internal/font/fonts/Terminus/ter-u16v.bdf
Normal file
31216
internal/font/fonts/Terminus/ter-u16v.bdf
Normal file
File diff suppressed because it is too large
Load Diff
33928
internal/font/fonts/Terminus/ter-u18b.bdf
Normal file
33928
internal/font/fonts/Terminus/ter-u18b.bdf
Normal file
File diff suppressed because it is too large
Load Diff
33928
internal/font/fonts/Terminus/ter-u18n.bdf
Normal file
33928
internal/font/fonts/Terminus/ter-u18n.bdf
Normal file
File diff suppressed because it is too large
Load Diff
36640
internal/font/fonts/Terminus/ter-u20b.bdf
Normal file
36640
internal/font/fonts/Terminus/ter-u20b.bdf
Normal file
File diff suppressed because it is too large
Load Diff
36640
internal/font/fonts/Terminus/ter-u20n.bdf
Normal file
36640
internal/font/fonts/Terminus/ter-u20n.bdf
Normal file
File diff suppressed because it is too large
Load Diff
39352
internal/font/fonts/Terminus/ter-u22b.bdf
Normal file
39352
internal/font/fonts/Terminus/ter-u22b.bdf
Normal file
File diff suppressed because it is too large
Load Diff
39352
internal/font/fonts/Terminus/ter-u22n.bdf
Normal file
39352
internal/font/fonts/Terminus/ter-u22n.bdf
Normal file
File diff suppressed because it is too large
Load Diff
42064
internal/font/fonts/Terminus/ter-u24b.bdf
Normal file
42064
internal/font/fonts/Terminus/ter-u24b.bdf
Normal file
File diff suppressed because it is too large
Load Diff
42064
internal/font/fonts/Terminus/ter-u24n.bdf
Normal file
42064
internal/font/fonts/Terminus/ter-u24n.bdf
Normal file
File diff suppressed because it is too large
Load Diff
47488
internal/font/fonts/Terminus/ter-u28b.bdf
Normal file
47488
internal/font/fonts/Terminus/ter-u28b.bdf
Normal file
File diff suppressed because it is too large
Load Diff
47488
internal/font/fonts/Terminus/ter-u28n.bdf
Normal file
47488
internal/font/fonts/Terminus/ter-u28n.bdf
Normal file
File diff suppressed because it is too large
Load Diff
52912
internal/font/fonts/Terminus/ter-u32b.bdf
Normal file
52912
internal/font/fonts/Terminus/ter-u32b.bdf
Normal file
File diff suppressed because it is too large
Load Diff
52912
internal/font/fonts/Terminus/ter-u32n.bdf
Normal file
52912
internal/font/fonts/Terminus/ter-u32n.bdf
Normal file
File diff suppressed because it is too large
Load Diff
280
internal/framebufferdisplay/README.md
Normal file
280
internal/framebufferdisplay/README.md
Normal 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.
|
@ -1,3 +1,4 @@
|
||||
// Package hdmistat provides the CLI commands for the hdmistat application
|
||||
package hdmistat
|
||||
|
||||
import (
|
||||
|
@ -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
|
||||
}),
|
||||
)
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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
442
internal/layout/draw.go
Normal 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}
|
||||
}
|
64
internal/layout/example_test.go
Normal file
64
internal/layout/example_test.go
Normal 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)
|
||||
}
|
@ -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)
|
||||
|
95
internal/layout/progressbar.go
Normal file
95
internal/layout/progressbar.go
Normal 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
259
internal/netmon/netmon.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
}
|
||||
|
246
internal/renderer/status_screen.go
Normal file
246
internal/renderer/status_screen.go
Normal 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
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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
248
scripts/docker-fb-test.sh
Executable 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
258
scripts/qemu-alpine-arm64.sh
Executable 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
335
scripts/qemu-fb-test.sh
Executable 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
338
scripts/qemu-rpi-test.sh
Executable 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
315
scripts/qemu-ubuntu-arm64.sh
Executable 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 "$@"
|
BIN
test/qemu-ubuntu-test/cloud-init.iso
Normal file
BIN
test/qemu-ubuntu-test/cloud-init.iso
Normal file
Binary file not shown.
2
test/qemu-ubuntu-test/cloud-init/meta-data
Normal file
2
test/qemu-ubuntu-test/cloud-init/meta-data
Normal file
@ -0,0 +1,2 @@
|
||||
instance-id: hdmistat-test-01
|
||||
local-hostname: hdmistat-test
|
101
test/qemu-ubuntu-test/cloud-init/user-data
Normal file
101
test/qemu-ubuntu-test/cloud-init/user-data
Normal 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!"
|
BIN
test/qemu-ubuntu-test/efivars.fd
Normal file
BIN
test/qemu-ubuntu-test/efivars.fd
Normal file
Binary file not shown.
72
test/qemu-ubuntu-test/run-qemu.sh
Executable file
72
test/qemu-ubuntu-test/run-qemu.sh
Executable 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."
|
4
test/qemu-ubuntu-test/ssh-ubuntu.sh
Executable file
4
test/qemu-ubuntu-test/ssh-ubuntu.sh
Executable 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
|
3
test/qemu-ubuntu-test/view-vnc.sh
Executable file
3
test/qemu-ubuntu-test/view-vnc.sh
Executable 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"
|
Loading…
Reference in New Issue
Block a user