feat(agent)!: add support for mTLS authentication in gRPC server (#54)

* refactor(fancontroller): improve fan controller validation logic and error handling for temperature steps

* refactor(agent): restructure gRPC server implementation by moving it to a new api package for better organization and maintainability

* feat(agent): implement gRPC server for managing compute blade agents and add graceful shutdown support
refactor(agent): restructure agent code by moving API logic to a dedicated file and improving error handling
fix(agent): update logging messages for clarity and consistency across the agent's operations
chore(agent): remove unused API code and consolidate event handling logic for better maintainability
style(agent): improve code formatting and organization for better readability and adherence to conventions

* feat(agent): add support for TLS configuration in gRPC server

* feat(api): add gRPC server authentication

* fix

* feat(config): add listen mode configuration to support tcp or unix sockets
feat(agent): implement listen mode in gRPC service to allow flexible socket types
feat(bladectl): enhance configuration loading and add support for TLS credentials
fix(bladectl): improve error handling for gRPC connection and event emission
style(logging): change log level from Warn to Info for better clarity in logs

* add logging middleware + fixes

* fix remote-connection to gRPC API Server

debugging the SAN issues took the soul out of me... And then the stupid
mistake in cmd_root where I didn't construct the TLS credentials
correctly... Oh dear...

* cleanup

* cleanup

* cleanup commands

* cleanup

* make README.md nicer

* Update cmd/agent/main.go

Co-authored-by: Matthias Riegler <github@m4tbit.de>

* Update cmd/bladectl/cmd_root.go

Co-authored-by: Matthias Riegler <github@m4tbit.de>

* move bladectl config into correct directory

* fix bugs

* // FIXME: No dead code

* nit: code style

* nit(YAGNI): you aint gonna need it. Don't make life harder than it needs to be

* nit(YAGNI): you aint gonna need it. Don't make life harder than it needs to be

* nit(YAGNI): you aint gonna need it. Don't make life harder than it needs to be

* nit(cmd_identify)

---------

Co-authored-by: Matthias Riegler <github@m4tbit.de>
This commit is contained in:
Cedric Kienzler
2025-05-12 00:00:55 +02:00
committed by GitHub
parent ec6229ad86
commit 70541d86ba
60 changed files with 2189 additions and 650 deletions

23
pkg/agent/agent.go Normal file
View File

@@ -0,0 +1,23 @@
package agent
import (
"context"
"github.com/uptime-industries/compute-blade-agent/pkg/events"
)
// ComputeBladeAgent implements the core-logic of the agent. It is responsible for handling events and interfacing with the hardware.
type ComputeBladeAgent interface {
// RunAsync dispatches the agent until the context is canceled or an error occurs
RunAsync(ctx context.Context, cancel context.CancelCauseFunc)
// Run dispatches the agent and blocks until the context is canceled or an error occurs
Run(ctx context.Context) error
// EmitEvent emits an event to the agent
EmitEvent(ctx context.Context, event events.Event) error
// SetFanSpeed sets the fan speed in percent
SetFanSpeed(_ context.Context, speed uint8) error
// SetStealthMode sets the stealth mode
SetStealthMode(_ context.Context, enabled bool) error
// WaitForIdentifyConfirm blocks until the user confirms the identify mode
WaitForIdentifyConfirm(ctx context.Context) error
}

115
pkg/agent/state.go Normal file
View File

@@ -0,0 +1,115 @@
package agent
import (
"context"
"sync"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/uptime-industries/compute-blade-agent/pkg/events"
"go.uber.org/zap"
)
var (
stateMetric = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: "computeblade_state",
Name: "state",
Help: "ComputeBlade state (label values are critical, identify, normal)",
}, []string{"state"})
)
type ComputebladeState interface {
RegisterEvent(event events.Event)
IdentifyActive() bool
WaitForIdentifyConfirm(ctx context.Context) error
CriticalActive() bool
WaitForCriticalClear(ctx context.Context) error
}
type computebladeStateImpl struct {
mutex sync.Mutex
// identifyActive indicates whether the blade is currently in identify mode
identifyActive bool
identifyConfirmChan chan struct{}
// criticalActive indicates whether the blade is currently in critical mode
criticalActive bool
criticalConfirmChan chan struct{}
}
func NewComputeBladeState() ComputebladeState {
return &computebladeStateImpl{
identifyConfirmChan: make(chan struct{}),
criticalConfirmChan: make(chan struct{}),
}
}
func (s *computebladeStateImpl) RegisterEvent(event events.Event) {
s.mutex.Lock()
defer s.mutex.Unlock()
switch event {
case events.IdentifyEvent:
s.identifyActive = true
case events.IdentifyConfirmEvent:
s.identifyActive = false
close(s.identifyConfirmChan)
s.identifyConfirmChan = make(chan struct{})
case events.CriticalEvent:
s.criticalActive = true
s.identifyActive = false
case events.CriticalResetEvent:
s.criticalActive = false
close(s.criticalConfirmChan)
s.criticalConfirmChan = make(chan struct{})
default:
zap.L().Warn("Unknown event", zap.String("event", event.String()))
}
// Set identify state metric
if s.identifyActive {
stateMetric.WithLabelValues("identify").Set(1)
} else {
stateMetric.WithLabelValues("identify").Set(0)
}
// Set critical state metric
if s.criticalActive {
stateMetric.WithLabelValues("critical").Set(1)
} else {
stateMetric.WithLabelValues("critical").Set(0)
}
// Set critical state metric
if !s.criticalActive && !s.identifyActive {
stateMetric.WithLabelValues("normal").Set(1)
} else {
stateMetric.WithLabelValues("normal").Set(0)
}
}
func (s *computebladeStateImpl) IdentifyActive() bool {
return s.identifyActive
}
func (s *computebladeStateImpl) WaitForIdentifyConfirm(ctx context.Context) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-s.identifyConfirmChan:
return nil
}
}
func (s *computebladeStateImpl) CriticalActive() bool {
return s.criticalActive
}
func (s *computebladeStateImpl) WaitForCriticalClear(ctx context.Context) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-s.criticalConfirmChan:
return nil
}
}

193
pkg/agent/state_test.go Normal file
View File

@@ -0,0 +1,193 @@
package agent_test
import (
"context"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/uptime-industries/compute-blade-agent/pkg/agent"
"github.com/uptime-industries/compute-blade-agent/pkg/events"
)
func TestNewComputeBladeState(t *testing.T) {
t.Parallel()
state := agent.NewComputeBladeState()
assert.NotNil(t, state)
}
func TestComputeBladeState_RegisterEventIdentify(t *testing.T) {
t.Parallel()
state := agent.NewComputeBladeState()
// Identify event
state.RegisterEvent(events.IdentifyEvent)
assert.True(t, state.IdentifyActive())
state.RegisterEvent(events.IdentifyConfirmEvent)
assert.False(t, state.IdentifyActive())
}
func TestComputeBladeState_RegisterEventCritical(t *testing.T) {
t.Parallel()
state := agent.NewComputeBladeState()
// critical event
state.RegisterEvent(events.CriticalEvent)
assert.True(t, state.CriticalActive())
state.RegisterEvent(events.CriticalResetEvent)
assert.False(t, state.CriticalActive())
}
func TestComputeBladeState_RegisterEventMixed(t *testing.T) {
t.Parallel()
state := agent.NewComputeBladeState()
// Send a bunch of events
state.RegisterEvent(events.CriticalEvent)
state.RegisterEvent(events.CriticalResetEvent)
state.RegisterEvent(events.NoopEvent)
state.RegisterEvent(events.CriticalEvent)
state.RegisterEvent(events.NoopEvent)
state.RegisterEvent(events.IdentifyEvent)
state.RegisterEvent(events.IdentifyEvent)
state.RegisterEvent(events.CriticalResetEvent)
state.RegisterEvent(events.IdentifyEvent)
assert.False(t, state.CriticalActive())
assert.True(t, state.IdentifyActive())
}
func TestComputeBladeState_WaitForIdentifyConfirm_NoTimeout(t *testing.T) {
t.Parallel()
state := agent.NewComputeBladeState()
// send identify event
t.Log("Setting identify event")
state.RegisterEvent(events.IdentifyEvent)
assert.True(t, state.IdentifyActive())
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
ctx := context.Background()
// Block until identify status is cleared
t.Log("Waiting for identify confirm")
err := state.WaitForIdentifyConfirm(ctx)
assert.NoError(t, err)
}()
// Give goroutine time to start
time.Sleep(50 * time.Millisecond)
// confirm event
state.RegisterEvent(events.IdentifyConfirmEvent)
t.Log("Identify event confirmed")
wg.Wait()
}
func TestComputeBladeState_WaitForIdentifyConfirm_Timeout(t *testing.T) {
t.Parallel()
state := agent.NewComputeBladeState()
// send identify event
t.Log("Setting identify event")
state.RegisterEvent(events.IdentifyEvent)
assert.True(t, state.IdentifyActive())
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond)
defer cancel()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
// Block until identify status is cleared
t.Log("Waiting for identify confirm")
err := state.WaitForIdentifyConfirm(ctx)
assert.ErrorIs(t, err, context.DeadlineExceeded)
}()
// Give goroutine time to start.
time.Sleep(50 * time.Millisecond)
// confirm event
state.RegisterEvent(events.IdentifyConfirmEvent)
t.Log("Identify event confirmed")
wg.Wait()
}
func TestComputeBladeState_WaitForCriticalClear_NoTimeout(t *testing.T) {
t.Parallel()
state := agent.NewComputeBladeState()
// send critical event
t.Log("Setting critical event")
state.RegisterEvent(events.CriticalEvent)
assert.True(t, state.CriticalActive())
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
ctx := context.Background()
// Block until critical status is cleared
t.Log("Waiting for critical confirm")
err := state.WaitForCriticalClear(ctx)
assert.NoError(t, err)
}()
// Give goroutine time to start
time.Sleep(50 * time.Millisecond)
// confirm event
state.RegisterEvent(events.CriticalResetEvent)
t.Log("critical event confirmed")
wg.Wait()
}
func TestComputeBladeState_WaitForCriticalClear_Timeout(t *testing.T) {
t.Parallel()
state := agent.NewComputeBladeState()
// send critical event
t.Log("Setting critical event")
state.RegisterEvent(events.CriticalEvent)
assert.True(t, state.CriticalActive())
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond)
defer cancel()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
// Block until critical status is cleared
t.Log("Waiting for critical confirm")
err := state.WaitForCriticalClear(ctx)
assert.ErrorIs(t, err, context.DeadlineExceeded)
}()
// Give goroutine time to start.
time.Sleep(50 * time.Millisecond)
// confirm event
state.RegisterEvent(events.CriticalResetEvent)
t.Log("critical event confirmed")
wg.Wait()
}

View File

@@ -0,0 +1,237 @@
package certificate
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"os"
"time"
"github.com/sierrasoftworks/humane-errors-go"
"github.com/uptime-industries/compute-blade-agent/pkg/util"
)
// LoadAndValidateCertificate loads and validates a certificate and its private key from the provided file paths.
// It reads, decodes, and parses the certificate and private key, ensuring the public key matches the private key.
// Returns the parsed X.509 certificate, ECDSA private key, and a humane.Error if any error occurs during processing.
func LoadAndValidateCertificate(certPath, keyPath string) (cert *x509.Certificate, key *ecdsa.PrivateKey, herr humane.Error) {
// Load and decode CA cert
certPEM, err := os.ReadFile(certPath)
if err != nil {
return nil, nil, humane.Wrap(err, "failed to read certificate",
fmt.Sprintf("ensure the certificate file %s exists and is readable by the agent user", certPath),
)
}
// Load and decode CA key
keyPEM, err := os.ReadFile(keyPath)
if err != nil {
return nil, nil, humane.Wrap(err, "failed to read private key",
fmt.Sprintf("ensure the key file %s exists and is readable by the agent user", keyPath),
)
}
return ValidateCertificate(certPEM, keyPEM)
}
// ValidateCertificate validates a PEM-encoded certificate and private key, ensuring the private key matches the certificate.
// Returns a parsed *x509.Certificate, *ecdsa.PrivateKey, or a humane.Error if any issue occurs during validation or parsing.
func ValidateCertificate(certPEM []byte, keyPEM []byte) (cert *x509.Certificate, key *ecdsa.PrivateKey, herr humane.Error) {
certBlock, _ := pem.Decode(certPEM)
if certBlock == nil {
return nil, nil, humane.New("failed to decode certificate",
"Verify if the certificate is valid by run the following command:",
"openssl x509 -in /path/to/certificate.pem -text -noout",
)
}
cert, err := x509.ParseCertificate(certBlock.Bytes)
if err != nil {
return nil, nil, humane.New("failed to parse certificate",
"Verify if the certificate is valid by run the following command:",
"openssl x509 -in /path/to/certificate.pem -text -noout",
)
}
keyBlock, _ := pem.Decode(keyPEM)
if keyBlock == nil {
return nil, nil, humane.New("failed to decode certificate",
"Verify if the key-file is valid by run the following command:",
"openssl ec -in /path/to/keyfile.pem -check",
)
}
key, err = x509.ParseECPrivateKey(keyBlock.Bytes)
if err != nil {
return nil, nil, humane.Wrap(err, "failed to parse private key",
"Verify if the key-file is valid by run the following command:",
"openssl ec -in /path/to/keyfile.pem -check",
)
}
// Compare public keys
certPub, ok := cert.PublicKey.(*ecdsa.PublicKey)
if !ok || certPub.X.Cmp(key.X) != 0 || certPub.Y.Cmp(key.Y) != 0 {
return nil, nil, humane.New("private key does not match certificate",
"Verify the certificate and private key match.",
"To verify on the CLI, use:",
fmt.Sprintf("cmp <(openssl x509 -in %s -pubkey -noout -outform PEM) <(openssl ec -in %s -pubout -outform PEM) && echo \"✅ Certificate and key match\" || echo \"❌ Mismatch\"",
"/path/to/certificate.pem",
"/path/to/keyfile.pem",
),
)
}
return cert, key, nil
}
// GenerateCertificate generates a certificate and private key based on provided options and outputs them in DER format.
// It supports client and server certificates, returning the certificate, private key, and an error if generation fails.
func GenerateCertificate(commonName string, opts ...Option) (certDER, keyDER []byte, herr humane.Error) {
options := &options{
Usage: UsageClient,
CaCert: nil,
CaKey: nil,
}
for _, opt := range opts {
opt(options)
}
hostname, err := os.Hostname()
if err != nil {
return nil, nil, humane.Wrap(err, "failed to extract hostname",
"this should never happen",
"please report this as a bug to https://github.com/uptime-industries/compute-blade-agent/issues",
)
}
var extKeyUsage []x509.ExtKeyUsage
var hostIps []net.IP
// If we generate server certificates
switch options.Usage {
case UsageClient:
extKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}
case UsageServer:
// make sure to use the correct key-usage
extKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}
// And add all the host-ips
if hostIps, err = util.GetHostIPs(); err != nil {
return nil, nil, humane.Wrap(err, "failed to extract server IPs",
"this should never happen",
"please report this as a bug to https://github.com/uptime-industries/compute-blade-agent/issues",
)
}
default:
return nil, nil, humane.New(fmt.Sprintf("invalid certificate usage %s", options.Usage.String()),
"this should never happen",
"please report this as a bug to https://github.com/uptime-industries/compute-blade-agent/issues",
)
}
certTemplate := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{
CommonName: commonName,
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: extKeyUsage,
DNSNames: []string{"localhost", hostname, fmt.Sprintf("%s.local", hostname)},
IPAddresses: hostIps,
}
clientKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil {
return nil, nil, humane.Wrap(err, "failed to generate client key",
"this should never happen",
"please report this as a bug to https://github.com/uptime-industries/compute-blade-agent/issues",
)
}
// prevent nil pointer exceptions by using the cert key as signing key and generate a
// self-signed certificate, if no CA is provided
signingCert := certTemplate
signingKey := clientKey
if options.CaCert != nil && options.CaKey != nil {
signingCert = options.CaCert
signingKey = options.CaKey
}
certDER, err = x509.CreateCertificate(rand.Reader, certTemplate, signingCert, &clientKey.PublicKey, signingKey)
if err != nil {
return nil, nil, humane.Wrap(err, "failed to create client certificate",
"this should never happen",
"please report this as a bug to https://github.com/uptime-industries/compute-blade-agent/issues",
)
}
clientKeyBytes, err := x509.MarshalECPrivateKey(clientKey)
if err != nil {
return nil, nil, humane.Wrap(err, "failed to marshal client private key",
"this should never happen",
"please report this as a bug to https://github.com/uptime-industries/compute-blade-agent/issues",
)
}
return certDER, clientKeyBytes, nil
}
// WriteCertificate writes a certificate and its private key to the specified file paths in PEM format.
// certPath specifies the file path to write the certificate PEM data.
// keyPath specifies the file path to write the private key PEM data.
// certDataDER is the DER-encoded certificate data to be written.
// keyDataDER is the DER-encoded private key data to be written.
// Returns a humane.Error if writing to the files fails.
func WriteCertificate(certPath, keyPath string, certDataDER []byte, keyDataDER []byte) humane.Error {
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDataDER})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDataDER})
if err := os.WriteFile(certPath, certPEM, 0600); err != nil {
return humane.Wrap(err, "failed to write certificate file",
"ensure the directory you are trying to create exists and is writable by the agent user",
)
}
if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
return humane.Wrap(err, "failed to write key file",
"ensure the directory you are trying to create exists and is writable by the agent user",
)
}
return nil
}
// GetCertPoolFrom reads a CA certificate from a given path and initializes a x509.CertPool with its contents.
// Returns the initialized certificate pool or a descriptive error if reading or appending the certificate fails.
func GetCertPoolFrom(caPath string) (pool *x509.CertPool, herr humane.Error) {
caCert, err := os.ReadFile(caPath)
if err != nil {
return nil, humane.Wrap(err, "failed to read CA certificate",
"ensure the directory you are trying to create exists and is writable by the agent user",
)
}
pool = x509.NewCertPool()
if !pool.AppendCertsFromPEM(caCert) {
return nil, humane.New("failed to append CA certificate to pool",
"this should never happen",
"please report this as a bug to https://github.com/uptime-industries/compute-blade-agent/issues",
"Verify if the CA certificate is valid by run the following command:",
fmt.Sprintf("openssl x509 -in %s -text -noout", caPath),
)
}
return pool, nil
}

View File

@@ -0,0 +1,40 @@
package certificate
import (
"crypto/ecdsa"
"crypto/x509"
)
type options struct {
CaCert *x509.Certificate
CaKey *ecdsa.PrivateKey
Usage Usage
}
type Option func(*options)
func WithUsage(usage Usage) Option {
return func(o *options) {
o.Usage = usage
}
}
func WithClientUsage() Option {
return WithUsage(UsageClient)
}
func WithServerUsage() Option {
return WithUsage(UsageServer)
}
func WithCaCert(cert *x509.Certificate) Option {
return func(o *options) {
o.CaCert = cert
}
}
func WithCaKey(key *ecdsa.PrivateKey) Option {
return func(o *options) {
o.CaKey = key
}
}

45
pkg/certificate/types.go Normal file
View File

@@ -0,0 +1,45 @@
package certificate
import "fmt"
// Usage defines the intended purpose of a certificate, such as client or server usage.
type Usage int
func (c Usage) String() string {
switch c {
case UsageClient:
return "client"
case UsageServer:
return "server"
default:
return fmt.Sprintf("CertificateUsage(%d)", c)
}
}
const (
UsageClient Usage = iota // Certificate is for Client
UsageServer // Certificate is for Server
)
// Format represents the encoding format of a certificate, such as PEM or DER.
type Format int
func (c Format) String() string {
switch c {
case FormatPEM:
return "pem"
case FormatDER:
return "der"
default:
return fmt.Sprintf("CertificateFormat(%d)", c)
}
}
const (
FormatPEM Format = iota // PEM Encoded Certificate
FormatDER // DER Encoded Certificate
)

31
pkg/events/event.go Normal file
View File

@@ -0,0 +1,31 @@
package events
type Event int
const (
NoopEvent = iota
IdentifyEvent
IdentifyConfirmEvent
CriticalEvent
CriticalResetEvent
EdgeButtonEvent
)
func (e Event) String() string {
switch e {
case NoopEvent:
return "noop"
case IdentifyEvent:
return "identify"
case IdentifyConfirmEvent:
return "identify_confirm"
case CriticalEvent:
return "critical"
case CriticalResetEvent:
return "critical_reset"
case EdgeButtonEvent:
return "edge_button"
default:
return "unknown"
}
}

View File

@@ -1,4 +1,4 @@
package eventbus
package events
import (
"sync"
@@ -27,7 +27,7 @@ type subscriber struct {
closed bool
}
func MatchAll(any) bool {
func MatchAll(_ any) bool {
return true
}

View File

@@ -1,17 +1,17 @@
package eventbus_test
package events_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/uptime-induestries/compute-blade-agent/pkg/eventbus"
"github.com/uptime-industries/compute-blade-agent/pkg/events"
)
func TestEventBusManySubscribers(t *testing.T) {
eb := eventbus.New()
eb := events.New()
// Create a channel and subscribe to a topic without a filter
sub0 := eb.Subscribe("topic0", 2, eventbus.MatchAll)
sub0 := eb.Subscribe("topic0", 2, events.MatchAll)
assert.Equal(t, cap(sub0.C()), 2)
assert.Equal(t, len(sub0.C()), 0)
defer sub0.Unsubscribe()
@@ -25,12 +25,12 @@ func TestEventBusManySubscribers(t *testing.T) {
defer sub1.Unsubscribe()
// Create a channel and subscribe to another topic
sub2 := eb.Subscribe("topic1", 1, eventbus.MatchAll)
sub2 := eb.Subscribe("topic1", 1, events.MatchAll)
assert.Equal(t, cap(sub2.C()), 1)
assert.Equal(t, len(sub2.C()), 0)
defer sub2.Unsubscribe()
sub3 := eb.Subscribe("topic1", 0, eventbus.MatchAll)
sub3 := eb.Subscribe("topic1", 0, events.MatchAll)
assert.Equal(t, cap(sub3.C()), 0)
assert.Equal(t, len(sub3.C()), 0)
defer sub3.Unsubscribe()
@@ -56,10 +56,10 @@ func TestEventBusManySubscribers(t *testing.T) {
}
func TestUnsubscribe(t *testing.T) {
eb := eventbus.New()
eb := events.New()
// Create a channel and subscribe to a topic
sub := eb.Subscribe("topic", 2, eventbus.MatchAll)
sub := eb.Subscribe("topic", 2, events.MatchAll)
// Unsubscribe from the topic
sub.Unsubscribe()

View File

@@ -0,0 +1,18 @@
package fancontroller
type FanOverrideOpts struct {
Percent uint8 `mapstructure:"speed"`
}
type Step struct {
// Temperature is the temperature to react to
Temperature float64 `mapstructure:"temperature"`
// Percent is the fan speed in percent
Percent uint8 `mapstructure:"percent"`
}
// Config configures a fan controller for the computeblade
type Config struct {
// Steps defines the temperature/speed steps for the fan controller
Steps []Step `mapstructure:"steps"`
}

View File

@@ -2,7 +2,10 @@ package fancontroller
import (
"fmt"
"sort"
"sync"
"github.com/sierrasoftworks/humane-errors-go"
)
type FanController interface {
@@ -10,45 +13,46 @@ type FanController interface {
GetFanSpeed(temperature float64) uint8
}
type FanOverrideOpts struct {
Percent uint8 `mapstructure:"speed"`
}
type FanControllerStep struct {
// Temperature is the temperature to react to
Temperature float64 `mapstructure:"temperature"`
// Percent is the fan speed in percent
Percent uint8 `mapstructure:"percent"`
}
// FanController configures a fan controller for the computeblade
type FanControllerConfig struct {
// Steps defines the temperature/speed steps for the fan controller
Steps []FanControllerStep `mapstructure:"steps"`
}
// FanController is a simple fan controller that reacts to temperature changes with a linear function
type fanControllerLinear struct {
mu sync.Mutex
mu sync.Mutex
overrideOpts *FanOverrideOpts
config FanControllerConfig
config Config
}
// NewFanControllerLinear creates a new FanControllerLinear
func NewLinearFanController(config FanControllerConfig) (FanController, error) {
// NewLinearFanController creates a new FanControllerLinear
func NewLinearFanController(config Config) (FanController, humane.Error) {
steps := config.Steps
// Validate config for a very simple linear fan controller
if len(config.Steps) != 2 {
return nil, fmt.Errorf("exactly two steps must be defined")
// Sort steps by temperature
sort.Slice(steps, func(i, j int) bool {
return steps[i].Temperature < steps[j].Temperature
})
for i := 0; i < len(steps)-1; i++ {
curr := steps[i]
next := steps[i+1]
if curr.Temperature >= next.Temperature {
return nil, humane.New("steps must have strictly increasing temperatures",
"Ensure that the temperatures are in ascending order and the ranges do not overlap",
fmt.Sprintf("Ensure defined temperature stepd %.2f is >= %.2f", curr.Temperature, next.Temperature),
)
}
if curr.Percent > next.Percent {
return nil, humane.New("fan percent must not decrease",
"Ensure that the fan percentages are not decreasing for higher temperatures",
fmt.Sprintf("Temperature %.2f is defined at %d%% and must be >= %d%% defined for temperature %.2f", curr.Temperature, curr.Percent, next.Percent, next.Temperature),
)
}
}
if config.Steps[0].Temperature > config.Steps[1].Temperature {
return nil, fmt.Errorf("step 1 temperature must be lower than step 2 temperature")
}
if config.Steps[0].Percent > config.Steps[1].Percent {
return nil, fmt.Errorf("step 1 speed must be lower than step 2 speed")
}
if config.Steps[0].Percent > 100 || config.Steps[1].Percent > 100 {
return nil, fmt.Errorf("speed must be between 0 and 100")
for _, step := range steps {
if step.Percent > 100 || step.Percent < 0 {
return nil, humane.New("fan percent must be between 0 and 100",
fmt.Sprintf("Ensure your fan percentage is 0 < %d < 100", step.Percent),
)
}
}
return &fanControllerLinear{

View File

@@ -1,17 +1,16 @@
// fancontroller_test.go
package fancontroller_test
import (
"testing"
"github.com/uptime-induestries/compute-blade-agent/pkg/fancontroller"
"github.com/uptime-industries/compute-blade-agent/pkg/fancontroller"
)
func TestFanControllerLinear_GetFanSpeed(t *testing.T) {
t.Parallel()
config := fancontroller.FanControllerConfig{
Steps: []fancontroller.FanControllerStep{
config := fancontroller.Config{
Steps: []fancontroller.Step{
{Temperature: 20, Percent: 30},
{Temperature: 30, Percent: 60},
},
@@ -47,8 +46,8 @@ func TestFanControllerLinear_GetFanSpeed(t *testing.T) {
func TestFanControllerLinear_GetFanSpeedWithOverride(t *testing.T) {
t.Parallel()
config := fancontroller.FanControllerConfig{
Steps: []fancontroller.FanControllerStep{
config := fancontroller.Config{
Steps: []fancontroller.Step{
{Temperature: 20, Percent: 30},
{Temperature: 30, Percent: 60},
},
@@ -87,47 +86,38 @@ func TestFanControllerLinear_GetFanSpeedWithOverride(t *testing.T) {
func TestFanControllerLinear_ConstructionErrors(t *testing.T) {
testCases := []struct {
name string
config fancontroller.FanControllerConfig
config fancontroller.Config
errMsg string
}{
{
name: "InvalidStepCount",
config: fancontroller.FanControllerConfig{
Steps: []fancontroller.FanControllerStep{
name: "Overlapping Step Temperatures",
config: fancontroller.Config{
Steps: []fancontroller.Step{
{Temperature: 20, Percent: 60},
{Temperature: 20, Percent: 30},
},
},
errMsg: "exactly two steps must be defined",
errMsg: "steps must have strictly increasing temperatures",
},
{
name: "InvalidStepTemperatures",
config: fancontroller.FanControllerConfig{
Steps: []fancontroller.FanControllerStep{
{Temperature: 30, Percent: 60},
{Temperature: 20, Percent: 30},
},
},
errMsg: "step 1 temperature must be lower than step 2 temperature",
},
{
name: "InvalidStepSpeeds",
config: fancontroller.FanControllerConfig{
Steps: []fancontroller.FanControllerStep{
name: "Percentages must not decrease",
config: fancontroller.Config{
Steps: []fancontroller.Step{
{Temperature: 20, Percent: 60},
{Temperature: 30, Percent: 30},
},
},
errMsg: "step 1 speed must be lower than step 2 speed",
errMsg: "fan percent must not decrease",
},
{
name: "InvalidSpeedRange",
config: fancontroller.FanControllerConfig{
Steps: []fancontroller.FanControllerStep{
config: fancontroller.Config{
Steps: []fancontroller.Step{
{Temperature: 20, Percent: 10},
{Temperature: 30, Percent: 200},
},
},
errMsg: "speed must be between 0 and 100",
errMsg: "fan percent must be between 0 and 100",
},
}

5
pkg/hal/config.go Normal file
View File

@@ -0,0 +1,5 @@
package hal
type Config struct {
RpmReporting bool `mapstructure:"rpm_reporting_standard_fan_unit"`
}

View File

@@ -6,8 +6,8 @@ import (
"context"
"log"
"github.com/uptime-induestries/compute-blade-agent/pkg/hal"
"github.com/uptime-induestries/compute-blade-agent/pkg/hal/led"
"github.com/uptime-industries/compute-blade-agent/pkg/hal"
"github.com/uptime-industries/compute-blade-agent/pkg/hal/led"
)
func ExampleNewSmartFanUnit() {
@@ -30,7 +30,7 @@ func ExampleNewSmartFanUnit() {
panic(err)
}
// Set fanspeed to 20%
// Set fan speed to 20%
err = client.SetFanSpeedPercent(ctx, 20)
if err != nil {
panic(err)

View File

@@ -3,7 +3,7 @@ package hal
import (
"context"
"github.com/uptime-induestries/compute-blade-agent/pkg/hal/led"
"github.com/uptime-industries/compute-blade-agent/pkg/hal/led"
)
type FanUnitKind uint8
@@ -49,17 +49,17 @@ type ComputeBladeHal interface {
Close() error
// SetFanSpeed sets the fan speed in percent
SetFanSpeed(speed uint8) error
// GetFanSpeed returns the current fan speed in percent (based on moving average)
// GetFanRPM returns the current fan speed in percent (based on moving average)
GetFanRPM() (float64, error)
// SetStealthMode enables/disables stealth mode of the blade (turning on/off the LEDs)
SetStealthMode(enabled bool) error
// SetLEDs sets the color of the LEDs
// SetLed sets the color of the LEDs
SetLed(idx uint, color led.Color) error
// GetPowerStatus returns the current power status of the blade
GetPowerStatus() (PowerStatus, error)
// GetTemperature returns the current temperature of the SoC in °C
GetTemperature() (float64, error)
// GetEdgeButtonPressChan returns a channel emitting edge button press events
// WaitForEdgeButtonPress returns a channel emitting edge button press events
WaitForEdgeButtonPress(ctx context.Context) error
}

View File

@@ -14,8 +14,8 @@ import (
"syscall"
"time"
"github.com/uptime-induestries/compute-blade-agent/pkg/hal/led"
"github.com/uptime-induestries/compute-blade-agent/pkg/log"
"github.com/uptime-industries/compute-blade-agent/pkg/hal/led"
"github.com/uptime-industries/compute-blade-agent/pkg/log"
"github.com/warthog618/gpiod"
"github.com/warthog618/gpiod/device/rpi"
"go.uber.org/zap"
@@ -192,7 +192,7 @@ func (bcm *bcm2711) setup(ctx context.Context) error {
defer cancel()
if smartFanUnitPresent, err := SmartFanUnitPresent(detectCtx, smartFanUnitDev); err == nil && smartFanUnitPresent {
log.FromContext(ctx).Error("detected smart fan unit")
log.FromContext(ctx).Info("detected smart fan unit")
bcm.fanUnit, err = NewSmartFanUnit(smartFanUnitDev)
if err != nil {
return err
@@ -204,7 +204,7 @@ func (bcm *bcm2711) setup(ctx context.Context) error {
bcm.gpioMem[bcm2711RegGpfsel1] = (bcm.gpioMem[bcm2711RegGpfsel1] &^ (0b111 << 6)) | (0b100 << 6)
bcm.fanUnit = &standardFanUnitBcm2711{
GpioChip0: bcm.gpioChip0,
DisableRPMreporting: !bcm.opts.RpmReportingStandardFanUnit,
DisableRpmReporting: !bcm.opts.RpmReportingStandardFanUnit,
SetFanSpeedPwmFunc: func(speed uint8) error {
bcm.setFanSpeedPWM(speed)
return nil
@@ -230,7 +230,7 @@ func (bcm *bcm2711) Run(parentCtx context.Context) error {
}
func (bcm *bcm2711) handleEdgeButtonEdge(evt gpiod.LineEvent) {
// Despite the debounce, we still get multiple events for a single button press
// Despite debouncing, we still get multiple events for a single button press
// -> This is an in-software debounce to ensure we only get one event per button press
select {
case bcm.edgeButtonDebounceChan <- struct{}{}:
@@ -424,7 +424,7 @@ func (bcm *bcm2711) updateLEDs() error {
ledColorChangeEventCount.Inc()
// Set frequency to 3*800khz.
// we'll bit-bang the data, so we'll need to send 3 bits per bit of data.
// we'll bit-bang the data, so we'll need to send 3 bits per one bit of data.
bcm.setPwm0Freq(3 * 800000)
time.Sleep(10 * time.Microsecond)

View File

@@ -6,14 +6,14 @@ import (
"context"
"time"
"github.com/uptime-induestries/compute-blade-agent/pkg/hal/led"
"github.com/uptime-industries/compute-blade-agent/pkg/hal/led"
"go.uber.org/zap"
)
// fails if SimulatedHal does not implement ComputeBladeHal
var _ ComputeBladeHal = &SimulatedHal{}
// ComputeBladeMock implements a mock for the ComputeBladeHal interface
// SimulatedHal implements a mock for the ComputeBladeHal interface
type SimulatedHal struct {
logger *zap.Logger
}

View File

@@ -6,7 +6,7 @@ import (
"context"
"math"
"github.com/uptime-induestries/compute-blade-agent/pkg/hal/led"
"github.com/uptime-industries/compute-blade-agent/pkg/hal/led"
"github.com/warthog618/gpiod"
"github.com/warthog618/gpiod/device/rpi"
)
@@ -14,16 +14,16 @@ import (
type standardFanUnitBcm2711 struct {
GpioChip0 *gpiod.Chip
SetFanSpeedPwmFunc func(speed uint8) error
DisableRPMreporting bool
DisableRpmReporting bool
// Fan tach input
// Fan tachometer input
fanEdgeLine *gpiod.Line
lastFanEdgeEvent *gpiod.LineEvent
fanRpm float64
}
func (fu standardFanUnitBcm2711) Kind() FanUnitKind {
if fu.DisableRPMreporting {
if fu.DisableRpmReporting {
return FanUnitKindStandardNoRPM
}
return FanUnitKindStandard
@@ -33,8 +33,8 @@ func (fu standardFanUnitBcm2711) Run(ctx context.Context) error {
var err error
fanUnit.WithLabelValues("standard").Set(1)
// Register edge event handler for fan tach input
if !fu.DisableRPMreporting {
// Register edge event handler for fan tachometer input
if !fu.DisableRpmReporting {
fu.fanEdgeLine, err = fu.GpioChip0.RequestLine(
rpi.GPIO13,
gpiod.WithEventHandler(fu.handleFanEdge),
@@ -51,7 +51,7 @@ func (fu standardFanUnitBcm2711) Run(ctx context.Context) error {
return ctx.Err()
}
// handleFanEdge handles an edge event on the fan tach input for the standard fan unite.
// handleFanEdge handles an edge event on the fan tachometer input for the standard fan unite.
// Exponential moving average is used to smooth out the fan speed.
func (fu *standardFanUnitBcm2711) handleFanEdge(evt gpiod.LineEvent) {
// Ensure we're always storing the last event

View File

@@ -4,13 +4,13 @@ import (
"context"
"github.com/stretchr/testify/mock"
"github.com/uptime-induestries/compute-blade-agent/pkg/hal/led"
"github.com/uptime-industries/compute-blade-agent/pkg/hal/led"
)
// fails if ComputeBladeHalMock does not implement ComputeBladeHal
var _ ComputeBladeHal = &ComputeBladeHalMock{}
// ComputeBladeMock implements a mock for the ComputeBladeHal interface
// ComputeBladeHalMock implements a mock for the ComputeBladeHal interface
type ComputeBladeHalMock struct {
mock.Mock
}

View File

@@ -1,4 +1,5 @@
//go:build linux
package hal
import (
@@ -8,11 +9,11 @@ import (
"unsafe"
)
func mmap(file *os.File, base int64, lenght int) ([]uint32, []uint8, error) {
func mmap(file *os.File, base int64, length int) ([]uint32, []uint8, error) {
mem8, err := syscall.Mmap(
int(file.Fd()),
base,
lenght,
length,
syscall.PROT_READ|syscall.PROT_WRITE,
syscall.MAP_SHARED,
)

View File

@@ -1,4 +1,5 @@
//go:build !tinygo
package hal
import (
@@ -10,7 +11,7 @@ var (
fanTargetPercent = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "fan_target_percent",
Help: "Target fanspeed in percent",
Help: "Target fan speed in percent",
})
fanSpeed = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: "computeblade",
@@ -29,7 +30,7 @@ var (
})
computeModule = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "compute_modul_present",
Name: "compute_module_present",
Help: "Compute module type",
}, []string{"type"})
ledColorChangeEventCount = promauto.NewCounter(prometheus.CounterOpts{

View File

@@ -8,11 +8,11 @@ import (
"io"
"sync"
"github.com/uptime-induestries/compute-blade-agent/pkg/eventbus"
"github.com/uptime-induestries/compute-blade-agent/pkg/hal/led"
"github.com/uptime-induestries/compute-blade-agent/pkg/log"
"github.com/uptime-induestries/compute-blade-agent/pkg/smartfanunit"
"github.com/uptime-induestries/compute-blade-agent/pkg/smartfanunit/proto"
"github.com/uptime-industries/compute-blade-agent/pkg/events"
"github.com/uptime-industries/compute-blade-agent/pkg/hal/led"
"github.com/uptime-industries/compute-blade-agent/pkg/log"
"github.com/uptime-industries/compute-blade-agent/pkg/smartfanunit"
"github.com/uptime-industries/compute-blade-agent/pkg/smartfanunit/proto"
"go.bug.st/serial"
"go.uber.org/zap"
"golang.org/x/sync/errgroup"
@@ -20,22 +20,30 @@ import (
func SmartFanUnitPresent(ctx context.Context, portName string) (bool, error) {
// Open the serial port.
log.FromContext(ctx).Warn("Opening serial port")
log.FromContext(ctx).Info("Opening serial port")
rwc, err := serial.Open(portName, &serial.Mode{
BaudRate: smartfanunit.Baudrate,
BaudRate: smartfanunit.BaudRate,
})
if err != nil {
return false, err
}
log.FromContext(ctx).Warn("Opened serial port")
defer rwc.Close()
log.FromContext(ctx).Info("Opened serial port")
defer func(rwc serial.Port) {
err := rwc.Close()
if err != nil {
log.FromContext(ctx).Warn("Error while closing serial port", zap.Error(err))
}
}(rwc)
// Close reader after context is done
go func() {
<-ctx.Done()
log.FromContext(ctx).Warn("Closing serial port")
rwc.Close()
err := rwc.Close()
if err != nil {
log.FromContext(ctx).Warn("Error while closing serial port", zap.Error(err))
}
}()
// read byte after byte, matching it to the SOF header used by the smart fan unit protocol.
@@ -56,7 +64,7 @@ func SmartFanUnitPresent(ctx context.Context, portName string) (bool, error) {
func NewSmartFanUnit(portName string) (FanUnit, error) {
// Open the serial port.
rwc, err := serial.Open(portName, &serial.Mode{
BaudRate: smartfanunit.Baudrate,
BaudRate: smartfanunit.BaudRate,
})
if err != nil {
return nil, err
@@ -64,15 +72,15 @@ func NewSmartFanUnit(portName string) (FanUnit, error) {
return &smartFanUnit{
rwc: rwc,
eb: eventbus.New(),
eb: events.New(),
}, nil
}
var ErrCommunicationFailed = errors.New("communication failed")
//var ErrCommunicationFailed = errors.New("communication failed") // FIXME: still required or dead code?
const (
inboundTopic = "smartfanunit:inbound"
outboundTopic = "smartfanunit:outbound"
inboundTopic = "smartfanunit:inbound"
//outboundTopic = "smartfanunit:outbound" // FIXME: still required or dead code?
)
type smartFanUnit struct {
@@ -82,7 +90,7 @@ type smartFanUnit struct {
speed smartfanunit.FanSpeedRPMPacket
airflow smartfanunit.AirFlowTemperaturePacket
eb eventbus.EventBus
eb events.EventBus
}
func (fuc *smartFanUnit) Kind() FanUnitKind {
@@ -126,7 +134,7 @@ func (fuc *smartFanUnit) Run(parentCtx context.Context) error {
return nil
case pktAny := <-sub.C():
rawPkt := pktAny.(proto.Packet)
if err := fuc.speed.FromPacket(rawPkt); err != nil && err != proto.ErrChecksumMismatch {
if err := fuc.speed.FromPacket(rawPkt); err != nil && !errors.Is(err, proto.ErrChecksumMismatch) {
return err
}
fanSpeed.Set(float64(fuc.speed.RPM))
@@ -144,7 +152,7 @@ func (fuc *smartFanUnit) Run(parentCtx context.Context) error {
return nil
case pktAny := <-sub.C():
rawPkt := pktAny.(proto.Packet)
if err := fuc.airflow.FromPacket(rawPkt); err != nil && err != proto.ErrChecksumMismatch {
if err := fuc.airflow.FromPacket(rawPkt); err != nil && !errors.Is(err, proto.ErrChecksumMismatch) {
return err
}
airFlowTemperature.Set(float64(fuc.airflow.Temperature))

View File

@@ -5,9 +5,9 @@ import (
"errors"
"time"
"github.com/uptime-induestries/compute-blade-agent/pkg/hal"
"github.com/uptime-induestries/compute-blade-agent/pkg/hal/led"
"github.com/uptime-induestries/compute-blade-agent/pkg/util"
"github.com/uptime-industries/compute-blade-agent/pkg/hal"
"github.com/uptime-industries/compute-blade-agent/pkg/hal/led"
"github.com/uptime-industries/compute-blade-agent/pkg/util"
)
// LedEngine is the interface for controlling effects on the computeblade RGB LEDs
@@ -36,21 +36,21 @@ type BlinkPattern struct {
Delays []time.Duration
}
func mapBrighnessUint8(brightness float64) uint8 {
func mapBrightnessUint8(brightness float64) uint8 {
return uint8(255.0 * brightness)
}
func LedColorPurple(brightness float64) led.Color {
return led.Color{
Red: mapBrighnessUint8(brightness),
Red: mapBrightnessUint8(brightness),
Green: 0,
Blue: mapBrighnessUint8(brightness),
Blue: mapBrightnessUint8(brightness),
}
}
func LedColorRed(brightness float64) led.Color {
return led.Color{
Red: mapBrighnessUint8(brightness),
Red: mapBrightnessUint8(brightness),
Green: 0,
Blue: 0,
}
@@ -59,7 +59,7 @@ func LedColorRed(brightness float64) led.Color {
func LedColorGreen(brightness float64) led.Color {
return led.Color{
Red: 0,
Green: mapBrighnessUint8(brightness),
Green: mapBrightnessUint8(brightness),
Blue: 0,
}
}
@@ -101,17 +101,7 @@ func NewSlowBlinkPattern(baseColor led.Color, activeColor led.Color) BlinkPatter
}
}
// LedEngineOpts are the options for the LedEngine
type LedEngineOpts struct {
// LedIdx is the index of the LED to control
LedIdx uint
// Hal is the computeblade hardware abstraction layer
Hal hal.ComputeBladeHal
// Clock is the clock used for timing
Clock util.Clock
}
func NewLedEngine(opts LedEngineOpts) *ledEngineImpl {
func NewLedEngine(opts Options) LedEngine {
clock := opts.Clock
if clock == nil {
clock = util.RealClock{}
@@ -119,7 +109,7 @@ func NewLedEngine(opts LedEngineOpts) *ledEngineImpl {
return &ledEngineImpl{
ledIdx: opts.LedIdx,
hal: opts.Hal,
restart: make(chan struct{}), // restart channel controls cancelation of any pattern
restart: make(chan struct{}), // restart channel controls cancellation of any pattern
pattern: NewStaticPattern(led.Color{}), // Turn off LEDs by default
clock: clock,
}

View File

@@ -9,10 +9,10 @@ import (
"time"
"github.com/stretchr/testify/assert"
"github.com/uptime-induestries/compute-blade-agent/pkg/hal"
"github.com/uptime-induestries/compute-blade-agent/pkg/hal/led"
"github.com/uptime-induestries/compute-blade-agent/pkg/ledengine"
"github.com/uptime-induestries/compute-blade-agent/pkg/util"
"github.com/uptime-industries/compute-blade-agent/pkg/hal"
"github.com/uptime-industries/compute-blade-agent/pkg/hal/led"
"github.com/uptime-industries/compute-blade-agent/pkg/ledengine"
"github.com/uptime-industries/compute-blade-agent/pkg/util"
)
func TestNewStaticPattern(t *testing.T) {
@@ -113,33 +113,9 @@ func TestNewBurstPattern(t *testing.T) {
}
}
func TestNewSlowBlinkPattern(t *testing.T) {
type args struct {
baseColor led.Color
activeColor led.Color
}
tests := []struct {
name string
args args
want ledengine.BlinkPattern
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ledengine.NewSlowBlinkPattern(tt.args.baseColor, tt.args.activeColor); !reflect.DeepEqual(
got,
tt.want,
) {
t.Errorf("NewSlowledengine.BlinkPattern() = %v, want %v", got, tt.want)
}
})
}
}
func TestNewLedEngine(t *testing.T) {
t.Parallel()
engine := ledengine.LedEngineOpts{
engine := ledengine.Options{
Clock: util.RealClock{},
LedIdx: 0,
Hal: &hal.ComputeBladeHalMock{},
@@ -158,7 +134,7 @@ func Test_LedEngine_SetPattern_WhileRunning(t *testing.T) {
cbMock.On("SetLed", uint(0), led.Color{Green: 0, Blue: 0, Red: 0}).Once().Return(nil)
cbMock.On("SetLed", uint(0), led.Color{Green: 0, Blue: 0, Red: 255}).Once().Return(nil)
opts := ledengine.LedEngineOpts{
opts := ledengine.Options{
Hal: &cbMock,
Clock: &clk,
LedIdx: 0,
@@ -204,7 +180,7 @@ func Test_LedEngine_SetPattern_BeforeRun(t *testing.T) {
cbMock := hal.ComputeBladeHalMock{}
cbMock.On("SetLed", uint(0), led.Color{Green: 0, Blue: 0, Red: 255}).Once().Return(nil)
opts := ledengine.LedEngineOpts{
opts := ledengine.Options{
Hal: &cbMock,
Clock: &clk,
LedIdx: 0,
@@ -247,7 +223,7 @@ func Test_LedEngine_SetPattern_SetLedFailureInPattern(t *testing.T) {
call0 := cbMock.On("SetLed", uint(0), led.Color{Green: 0, Blue: 0, Red: 0}).Once().Return(nil)
cbMock.On("SetLed", uint(0), led.Color{Green: 0, Blue: 0, Red: 0}).Once().Return(errors.New("failure")).NotBefore(call0)
opts := ledengine.LedEngineOpts{
opts := ledengine.Options{
Hal: &cbMock,
Clock: &clk,
LedIdx: 0,

16
pkg/ledengine/options.go Normal file
View File

@@ -0,0 +1,16 @@
package ledengine
import (
"github.com/uptime-industries/compute-blade-agent/pkg/hal"
"github.com/uptime-industries/compute-blade-agent/pkg/util"
)
// Options are the options for the LedEngine
type Options struct {
// LedIdx is the index of the LED to control
LedIdx uint
// Hal is the computeblade hardware abstraction layer
Hal hal.ComputeBladeHal
// Clock is the clock used for timing
Clock util.Clock
}

View File

@@ -0,0 +1,47 @@
package log
import (
"context"
"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/logging"
"go.uber.org/zap"
)
// InterceptorLogger adapts zap logger to interceptor logger.
// This code is simple enough to be copied and not imported.
func InterceptorLogger(l *zap.Logger) logging.Logger {
return logging.LoggerFunc(func(ctx context.Context, lvl logging.Level, msg string, fields ...any) {
f := make([]zap.Field, 0, len(fields)/2)
for i := 0; i < len(fields); i += 2 {
key := fields[i]
value := fields[i+1]
switch v := value.(type) {
case string:
f = append(f, zap.String(key.(string), v))
case int:
f = append(f, zap.Int(key.(string), v))
case bool:
f = append(f, zap.Bool(key.(string), v))
default:
f = append(f, zap.Any(key.(string), v))
}
}
logger := zap.L().WithOptions(zap.AddCallerSkip(4)).With(f...)
switch lvl {
case logging.LevelDebug:
logger.Debug(msg)
case logging.LevelInfo:
logger.Info(msg)
case logging.LevelWarn:
logger.Warn(msg)
case logging.LevelError:
logger.Error(msg)
default:
logger.Warn(msg, zap.String("error", "unknown level"), zap.Int("level", int(lvl)))
}
})
}

View File

@@ -17,6 +17,7 @@ func FromContext(ctx context.Context) *zap.Logger {
if val != nil {
return val.(*zap.Logger)
}
zap.L().Warn("No logger in context, passing default")
zap.L().WithOptions(zap.AddCallerSkip(1)).Warn("No logger in context, passing default")
return zap.L()
}

View File

@@ -3,19 +3,29 @@ package smartfanunit
import (
"errors"
"github.com/uptime-induestries/compute-blade-agent/pkg/hal/led"
"github.com/uptime-induestries/compute-blade-agent/pkg/smartfanunit/proto"
"github.com/uptime-industries/compute-blade-agent/pkg/hal/led"
"github.com/uptime-industries/compute-blade-agent/pkg/smartfanunit/proto"
)
// Blade -> FanUnit communication
const (
// Blade -> FanUnit
// CmdSetFanSpeedPercent sets the fan speed as a percentage, sent from the blade to the fan unit.
CmdSetFanSpeedPercent proto.Command = 0x01
CmdSetLED proto.Command = 0x02
// FanUnit -> Blade, sent in regular intervals
NotifyButtonPress proto.Command = 0xa1
// CmdSetLED represents the command to set the LED color, sent from the blade to the fan unit.
CmdSetLED proto.Command = 0x02
)
// FanUnit -> Blade, sent in regular intervals
const (
// NotifyButtonPress represents a command sent from the fan unit to indicate a button press event.
NotifyButtonPress proto.Command = 0xa1
// NotifyAirFlowTemperature represents a command sent from the fan unit to report the current air flow temperature.
NotifyAirFlowTemperature proto.Command = 0xa2
NotifyFanSpeedRPM proto.Command = 0xa3
// NotifyFanSpeedRPM is a command used to report the current fan speed in RPM from the fan unit to the blade.
NotifyFanSpeedRPM proto.Command = 0xa3
)
var ErrInvalidCommand = errors.New("invalid command")
@@ -93,7 +103,7 @@ type AirFlowTemperaturePacket struct {
func (p *AirFlowTemperaturePacket) Packet() proto.Packet {
return proto.Packet{
Command: NotifyAirFlowTemperature,
Data: proto.Data(float32To24Bit(p.Temperature)),
Data: float32To24Bit(p.Temperature),
}
}

View File

@@ -1,4 +1,4 @@
// This is a driver for the EMC2101 fan controller
// Package emc2101 is a driver for the EMC2101 fan controller
// Based on https://ww1.microchip.com/downloads/en/DeviceDoc/2101.pdf
package emc2101
@@ -57,7 +57,6 @@ func (e *emc2101) updateReg(regAddr, setMask, clearMask uint8) error {
return nil
}
return e.bus.Tx(e.Address, []byte{regAddr, toWrite}, nil)
}
@@ -74,10 +73,10 @@ func (e *emc2101) Init() error {
}
/*
0x3 0b100
0x4b 0b11111
0x4a 0b100000
0x4a 0b100000
0x3 0b100
0x4b 0b11111
0x4a 0b100000
0x4a 0b100000
*/
// Configure fan spin up to ignore tach input
@@ -126,7 +125,7 @@ func (e *emc2101) FanRPM() (float32, error) {
return 0, err
}
var tachCount int = int(high[0])<<8 | int(low[0])
var tachCount = int(high[0])<<8 | int(low[0])
return float32(5400000) / float32(tachCount), nil
}

View File

@@ -1,6 +1,6 @@
package smartfanunit
import "github.com/uptime-induestries/compute-blade-agent/pkg/smartfanunit/proto"
import "github.com/uptime-industries/compute-blade-agent/pkg/smartfanunit/proto"
func float32To24Bit(val float32) proto.Data {
// Convert float32 to number with 3 bytes (0.1 precision)

View File

@@ -78,7 +78,7 @@ func WritePacket(_ context.Context, w io.Writer, packet Packet) error {
// ReadPacket reads a packet from an io.Reader with escaping.
// This is blocking and drops invalid bytes until a valid packet is received.
func ReadPacket(ctx context.Context, r io.Reader) (Packet, error) {
buffer := []uint8{}
var buffer []uint8
started := false
escaped := false

View File

@@ -6,7 +6,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/uptime-induestries/compute-blade-agent/pkg/smartfanunit/proto"
"github.com/uptime-industries/compute-blade-agent/pkg/smartfanunit/proto"
)
func TestWritePacket(t *testing.T) {

View File

@@ -1,11 +1,11 @@
package smartfanunit
import (
"github.com/uptime-induestries/compute-blade-agent/pkg/smartfanunit/proto"
"github.com/uptime-industries/compute-blade-agent/pkg/smartfanunit/proto"
)
const (
Baudrate = 115200
BaudRate = 115200
)
func MatchCmd(cmd proto.Command) func(any) bool {

9
pkg/util/file_exists.go Normal file
View File

@@ -0,0 +1,9 @@
package util
import "os"
// FileExists checks if a file exists at the given path and returns true if it does, false otherwise.
func FileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}

43
pkg/util/host_ips.go Normal file
View File

@@ -0,0 +1,43 @@
package util
import "net"
func GetHostIPs() ([]net.IP, error) {
var ips []net.IP
ifaces, err := net.Interfaces()
if err != nil {
return nil, err
}
for _, iface := range ifaces {
// Skip down or loopback interfaces
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue // skip interfaces we can't read
}
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
// Skip loopback or unspecified
if ip == nil || ip.IsLoopback() || ip.IsUnspecified() {
continue
}
ips = append(ips, ip)
}
}
return ips, nil
}