feat: add smart fan unit support (#29)

* feat: add smart fanunit (serial) protocol

Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>

* feat: add rudimentary eventbus to ease implementation

Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>

* feat: smart fanunit client

Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>

* feat: initial smart fan unit implementation

Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>

* feat: improve logging, double btn press

Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>

* fix: testcases

Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>

* fix: context closure handling, RPM reporting

Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>

* fix: address linting issues

Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>

* fix: edge line closure

Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>

* fix: reset CPU after i2c lockup

Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>

* feat: add uf2 to release

Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>

---------

Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>
This commit is contained in:
Matthias Riegler
2023-11-25 11:07:50 +01:00
committed by GitHub
parent c6bba1339b
commit 99920370fb
33 changed files with 1975 additions and 257 deletions

View File

@@ -52,6 +52,11 @@ jobs:
path: ${{ steps.go-cache-paths.outputs.go-mod }}
key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
# Setup tinygo
- uses: acifani/setup-tinygo@v2
with:
tinygo-version: '0.30.0'
# Setup docker buildx
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -68,8 +73,12 @@ jobs:
- name: Install Cosign
uses: sigstore/cosign-installer@v3
# Build fanunit firmware
- name: Build FanUnit Firmware
run: make build-fanunit
# Run goreleaser
- name: Goreleaser
- name: Run Goreleaser
uses: goreleaser/goreleaser-action@v5
with:
version: latest

2
.gitignore vendored
View File

@@ -3,3 +3,5 @@ dist/
*.test
*.out
cover.cov
fanunit.u2f

View File

@@ -79,7 +79,12 @@ nfpms:
contents:
- src: ./hack/systemd/computeblade-agent.service
dst: /etc/systemd/system/computeblade-agent.service
type: config
- src: ./cmd/agent/default-config.yaml
dst: /etc/computeblade-agent/config.yaml
type: config
type: config|noreplace
- src: ./fanunit.uf2
dst: /usr/share/computeblade-agent/fanunit.uf2
release:
extra_files:
- glob: ./fanunit.uf2

View File

@@ -1,5 +1,6 @@
FUZZ_TARGETS := ./pkg/smartfanunit/proto
all: lint
all: lint test
.PHONY: run
run:
@@ -13,15 +14,29 @@ lint:
test:
go test ./... -v
.PHONY: fuzz
fuzz:
@for target in $(FUZZ_TARGETS); do \
go test -fuzz="Fuzz" -fuzztime=5s -fuzzminimizetime=10s $$target; \
done
.PHONY: generate
generate: buf
$(BUF) generate
release:
goreleaser release --clean
.PHONY: build-fanunit
build-fanunit:
tinygo build -target=pico -o fanunit.uf2 ./cmd/fanunit/
.PHONY: build-agent
build-agent: generate
goreleaser build --snapshot --clean
.PHONY: snapshot
snapshot:
goreleaser release --snapshot --skip-publish --clean
goreleaser release --snapshot --skip=publish --clean
# Dependencies
LOCALBIN ?= $(shell pwd)/bin

View File

@@ -10,13 +10,12 @@ listen:
# Hardware abstraction layer configuration
hal:
bcm2711:
# For the default fan unit, fanspeed measurement is causing a tiny bit of CPU laod.
# Sometimes it might not be desired
disable_fanspeed_measurement: false
# For the default fan unit, fanspeed measurement is causing a tiny bit of CPU laod.
# Sometimes it might not be desired
rpm_reporting_standard_fan_unit: true
# Idle LED color, values range from 0-255
idle_led_color:
idle_led_color:
red: 0
green: 16
blue: 0

View File

@@ -36,7 +36,7 @@ func main() {
// Setup configuration
viper.SetConfigType("yaml")
// auto-bind environment variables
viper.SetEnvPrefix("AGENT")
viper.SetEnvPrefix("BLADE")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv()
// Load potential file configs
@@ -56,7 +56,9 @@ func main() {
}
zapLogger := baseLogger.With(zap.String("app", "computeblade-agent"))
defer zapLogger.Sync()
defer func() {
_ = zapLogger.Sync()
}()
_ = zap.ReplaceGlobals(zapLogger.With(zap.String("scope", "global")))
baseCtx := log.IntoContext(context.Background(), zapLogger)
@@ -70,12 +72,6 @@ func main() {
cancelCtx(err)
}
computebladeAgent, err := agent.NewComputeBladeAgent(cbAgentConfig)
if err != nil {
log.FromContext(ctx).Error("Failed to create agent", zap.Error(err))
cancelCtx(err)
}
// setup stop signal handlers
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
@@ -91,10 +87,19 @@ func main() {
}
}()
log.FromContext(ctx).Info("Bootstrapping computeblade-agent", zap.String("version", viper.GetString("version")))
computebladeAgent, err := agent.NewComputeBladeAgent(ctx, cbAgentConfig)
if err != nil {
log.FromContext(ctx).Error("Failed to create agent", zap.Error(err))
cancelCtx(err)
os.Exit(1)
}
// Run agent
wg.Add(1)
go func() {
defer wg.Done()
log.FromContext(ctx).Info("Starting agent")
err := computebladeAgent.Run(ctx)
if err != nil && err != context.Canceled {
log.FromContext(ctx).Error("Failed to run agent", zap.Error(err))

View File

@@ -45,18 +45,6 @@ func clientFromContext(ctx context.Context) bladeapiv1alpha1.BladeAgentServiceCl
return client
}
func grpcConnIntoContext(ctx context.Context, grpcConn *grpc.ClientConn) context.Context {
return context.WithValue(ctx, defaultGrpcClientConnContextKey, grpcConn)
}
func grpcConnFromContext(ctx context.Context) *grpc.ClientConn {
grpcConn, ok := ctx.Value(defaultGrpcClientContextKey).(*grpc.ClientConn)
if !ok {
panic("grpc client connection not found in context")
}
return grpcConn
}
var rootCmd = &cobra.Command{
Use: "bladectl",
Short: "bladectl interacts with the computeblade-agent and allows you to manage hardware-features of your compute blade(s)",
@@ -85,9 +73,7 @@ var rootCmd = &cobra.Command{
}
client := bladeapiv1alpha1.NewBladeAgentServiceClient(conn)
cmd.SetContext(
grpcConnIntoContext(clientIntoContext(ctx, client), conn),
)
cmd.SetContext( clientIntoContext(ctx, client))
return nil
},
}

261
cmd/fanunit/controller.go Normal file
View File

@@ -0,0 +1,261 @@
//go:build tinygo
package main
import (
"context"
"machine"
"time"
"github.com/xvzf/computeblade-agent/pkg/eventbus"
"github.com/xvzf/computeblade-agent/pkg/hal/led"
"github.com/xvzf/computeblade-agent/pkg/smartfanunit"
"github.com/xvzf/computeblade-agent/pkg/smartfanunit/emc2101"
"github.com/xvzf/computeblade-agent/pkg/smartfanunit/proto"
"golang.org/x/sync/errgroup"
"tinygo.org/x/drivers"
"tinygo.org/x/drivers/ws2812"
)
const (
leftBladeTopicIn = "left:in"
leftBladeTopicOut = "left:out"
rightBladeTopicIn = "right:in"
rightBladeTopicOut = "right:out"
)
type Controller struct {
DefaultFanSpeed uint8
LEDs ws2812.Device
FanController emc2101.EMC2101
ButtonPin machine.Pin
LeftUART drivers.UART
RightUART drivers.UART
eb eventbus.EventBus
leftLed led.Color
rightLed led.Color
leftReqFanSpeed uint8
rightReqFanSpeed uint8
buttonPressed int
}
func (c *Controller) Run(parentCtx context.Context) error {
c.eb = eventbus.New()
c.FanController.Init()
c.FanController.SetFanPercent(c.DefaultFanSpeed)
c.LEDs.Write([]byte{0, 0, 0, 0, 0, 0})
group, ctx := errgroup.WithContext(parentCtx)
// LED Update events
println("[+] Starting LED update loop")
group.Go(func() error {
return c.updateLEDs(ctx)
})
// Fan speed update events
println("[+] Starting fan update loop")
group.Go(func() error {
return c.updateFanSpeed(ctx)
})
// Metric reporting events
println("[+] Starting metric reporting loop")
group.Go(func() error {
return c.metricReporter(ctx);
})
// Left blade events
println("[+] Starting event listener (left)")
group.Go(func() error {
return c.listenEvents(ctx, c.LeftUART, leftBladeTopicIn)
})
println("[+] Starting event dispatcher (left)")
group.Go(func() error {
return c.dispatchEvents(ctx, c.LeftUART, leftBladeTopicOut)
})
// right blade events
println("[+] Starting event listener (righ)")
group.Go(func() error {
return c.listenEvents(ctx, c.RightUART, rightBladeTopicIn)
})
println("[+] Starting event dispatcher (right)")
group.Go(func() error {
return c.dispatchEvents(ctx, c.RightUART, rightBladeTopicOut)
})
// Button Press events
println("[+] Starting button interrupt handler")
c.ButtonPin.SetInterrupt(machine.PinFalling, func(machine.Pin) {
c.buttonPressed += 1
})
group.Go(func() error {
ticker := time.NewTicker(20 * time.Millisecond)
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
btnPressed := smartfanunit.ButtonPressPacket{}
if c.buttonPressed > 0 {
// Allow up to 600ms for a 2nc button press
time.Sleep(600 * time.Millisecond)
}
if c.buttonPressed == 1 {
println("[ ] Button pressed once")
c.eb.Publish(leftBladeTopicOut, btnPressed.Packet())
}
if c.buttonPressed == 2 {
println("[ ] Button pressed twice")
c.eb.Publish(rightBladeTopicOut, btnPressed.Packet())
}
c.buttonPressed = 0
}
}
})
return group.Wait()
}
// listenEvents reads events from the UART interface and dispatches them to the eventbus
func (c *Controller) listenEvents(ctx context.Context, uart drivers.UART, targetTopic string) error {
for {
// Read packet from UART; blocks until packet is received
pkt, err := proto.ReadPacket(ctx, uart)
if err != nil {
println("[!] failed to read packet, continuing..", err.Error())
continue
}
println("[ ] received packet from UART publishing to topic", targetTopic)
c.eb.Publish(targetTopic, pkt)
}
}
// dispatchEvents reads events from the eventbus and writes them to the UART interface
func (c *Controller) dispatchEvents(ctx context.Context, uart drivers.UART, sourceTopic string) error {
sub := c.eb.Subscribe(sourceTopic, 4, eventbus.MatchAll)
defer sub.Unsubscribe()
for {
select {
case msg := <-sub.C():
println("[ ] dispatching event to UART from topic", sourceTopic)
pkt := msg.(proto.Packet)
err := proto.WritePacket(ctx, uart, pkt)
if err != nil {
println(err.Error())
}
case <-ctx.Done():
return nil
}
}
}
func (c *Controller) metricReporter(ctx context.Context) error {
var err error
ticker := time.NewTicker(2 * time.Second)
airFlowTempRight := smartfanunit.AirFlowTemperaturePacket{}
airFlowTempLeft := smartfanunit.AirFlowTemperaturePacket{}
fanRpm := smartfanunit.FanSpeedRPMPacket{}
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
}
airFlowTempLeft.Temperature, err = c.FanController.InternalTemperature()
if err != nil {
println("[!] failed to read internal temperature:", err.Error())
}
airFlowTempRight.Temperature, err = c.FanController.ExternalTemperature()
if err != nil {
println("[!] failed to read external temperature:", err.Error())
}
fanRpm.RPM, err = c.FanController.FanRPM()
if err != nil {
println("[!] failed to read fan RPM:", err.Error())
}
// FIXME: This is a workaround for an i2c lockup issue.
if err != nil {
println("[!] resetting CPU")
time.Sleep(100*time.Millisecond)
machine.CPUReset()
}
// Publish metrics
c.eb.Publish(leftBladeTopicOut, airFlowTempLeft.Packet())
c.eb.Publish(rightBladeTopicOut, airFlowTempRight.Packet())
c.eb.Publish(leftBladeTopicOut, fanRpm.Packet())
c.eb.Publish(rightBladeTopicOut, fanRpm.Packet())
}
}
func (c *Controller) updateFanSpeed(ctx context.Context) error {
var pkt smartfanunit.SetFanSpeedPercentPacket
subLeft := c.eb.Subscribe(leftBladeTopicIn, 1, eventbus.MatchAll)
defer subLeft.Unsubscribe()
subRight := c.eb.Subscribe(rightBladeTopicIn, 1, eventbus.MatchAll)
defer subRight.Unsubscribe()
for {
// Update LED color depending on blade
select {
case msg := <-subLeft.C():
pkt.FromPacket(msg.(proto.Packet))
c.leftReqFanSpeed = pkt.Percent
case msg := <-subRight.C():
pkt.FromPacket(msg.(proto.Packet))
c.rightReqFanSpeed = pkt.Percent
case <-ctx.Done():
return nil
}
// Update fan speed with the max requested speed
if c.leftReqFanSpeed > c.rightReqFanSpeed {
c.FanController.SetFanPercent(c.leftReqFanSpeed)
} else {
c.FanController.SetFanPercent(c.rightReqFanSpeed)
}
}
}
func (c *Controller) updateLEDs(ctx context.Context) error {
subLeft := c.eb.Subscribe(leftBladeTopicIn, 1, smartfanunit.MatchCmd(smartfanunit.CmdSetLED))
defer subLeft.Unsubscribe()
subRight := c.eb.Subscribe(rightBladeTopicIn, 1, smartfanunit.MatchCmd(smartfanunit.CmdSetLED))
defer subRight.Unsubscribe()
var pkt smartfanunit.SetLEDPacket
for {
// Update LED color depending on blade
select {
case msg := <-subLeft.C():
pkt.FromPacket(msg.(proto.Packet))
c.leftLed = pkt.Color
case msg := <-subRight.C():
pkt.FromPacket(msg.(proto.Packet))
c.rightLed = pkt.Color
case <-time.After(500 * time.Millisecond):
case <-ctx.Done():
return nil
}
// Write to LEDs (they are in a chain -> we always have to update both)
_, err := c.LEDs.Write([]byte{
c.rightLed.Blue, c.rightLed.Green, c.rightLed.Red,
c.leftLed.Blue, c.leftLed.Green, c.leftLed.Red,
})
if err != nil {
println("[!] failed to update LEDs", err.Error())
return err
}
}
}

87
cmd/fanunit/main.go Normal file
View File

@@ -0,0 +1,87 @@
//go:build tinygo
package main
import (
"context"
"machine"
"time"
"github.com/xvzf/computeblade-agent/pkg/smartfanunit"
"github.com/xvzf/computeblade-agent/pkg/smartfanunit/emc2101"
"tinygo.org/x/drivers/ws2812"
)
func main() {
var controller *Controller
var emc emc2101.EMC2101
var bgrLeds ws2812.Device
var err error
// Configure status LED
machine.LED.Configure(machine.PinConfig{Mode: machine.PinOutput})
machine.LED.Set(false)
// Configure UARTs
err = machine.UART0.Configure(machine.UARTConfig{TX: machine.UART0_TX_PIN, RX: machine.UART0_RX_PIN})
if err != nil {
println("[!] Failed to initialize UART0:", err.Error())
goto errprint
}
machine.UART0.SetBaudRate(smartfanunit.Baudrate)
err = machine.UART1.Configure(machine.UARTConfig{TX: machine.UART1_TX_PIN, RX: machine.UART1_RX_PIN})
if err != nil {
println("[!] Failed to initialize UART1:", err.Error())
goto errprint
}
machine.UART1.SetBaudRate(smartfanunit.Baudrate)
// Enables fan, DO NOT CHANGE
machine.GP16.Configure(machine.PinConfig{Mode: machine.PinOutput})
machine.GP16.Set(true)
// WS2812 LEDs
machine.GP15.Configure(machine.PinConfig{Mode: machine.PinOutput})
bgrLeds = ws2812.New(machine.GP15)
// Configure button
machine.GP12.Configure(machine.PinConfig{Mode: machine.PinInput})
// Setup emc2101
machine.I2C0.Configure(machine.I2CConfig{
Frequency: 100 * machine.KHz,
SDA: machine.I2C0_SDA_PIN,
SCL: machine.I2C0_SCL_PIN,
})
emc = emc2101.New(machine.I2C0)
err = emc.Init()
if err != nil {
println("[!] Failed to initialize emc2101:", err.Error())
goto errprint
}
println("[+] IO initialized, starting controller...")
// Run controller
controller = &Controller{
DefaultFanSpeed: 40,
LEDs: bgrLeds,
FanController: emc,
ButtonPin: machine.GP12,
LeftUART: machine.UART0,
RightUART: machine.UART1,
}
err = controller.Run(context.Background())
// Blinking -> something went wrong
errprint:
ledState := false
for {
ledState = !ledState
machine.LED.Set(ledState)
// Repeat error message
println("[FATAL] controller exited with error:", err)
time.Sleep(500 * time.Millisecond)
}
}

BIN
fanunit.uf2 Normal file

Binary file not shown.

12
go.mod
View File

@@ -1,24 +1,31 @@
module github.com/xvzf/computeblade-agent
go 1.20
go 1.21
toolchain go1.21.3
require (
github.com/jacobsa/go-serial v0.0.0-20180131005756-15cf729a72d4
github.com/prometheus/client_golang v1.16.0
github.com/spf13/cobra v1.6.1
github.com/spf13/viper v1.16.0
github.com/stretchr/testify v1.8.3
github.com/warthog618/gpiod v0.8.1
go.uber.org/zap v1.24.0
golang.org/x/sync v0.2.0
google.golang.org/grpc v1.56.2
google.golang.org/protobuf v1.31.0
tinygo.org/x/drivers v0.26.0
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/creack/goselect v0.1.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
@@ -35,10 +42,11 @@ require (
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/subosito/gotenv v1.4.2 // indirect
go.bug.st/serial v1.6.1 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.10.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/text v0.9.0 // indirect
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect

23
go.sum
View File

@@ -39,6 +39,7 @@ dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
@@ -52,6 +53,8 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
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=
@@ -62,6 +65,7 @@ github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5y
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY=
github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
@@ -108,6 +112,7 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
@@ -122,6 +127,8 @@ github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLe
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
@@ -135,15 +142,19 @@ github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jacobsa/go-serial v0.0.0-20180131005756-15cf729a72d4 h1:G2ztCwXov8mRvP0ZfjE6nAlaCX2XbykaeHdbT6KwDz0=
github.com/jacobsa/go-serial v0.0.0-20180131005756-15cf729a72d4/go.mod h1:2RvX5ZjVtsznNZPEt4xwJXNJrM3VTZoQf7V6gk0ysvs=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
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/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
@@ -153,6 +164,7 @@ github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RR
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pilebones/go-udev v0.9.0 h1:N1uEO/SxUwtIctc0WLU0t69JeBxIYEYnj8lT/Nabl9Q=
github.com/pilebones/go-udev v0.9.0/go.mod h1:T2eI2tUSK0hA2WS5QLjXJUfQkluZQu+18Cqvem3CaXI=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
@@ -169,6 +181,7 @@ github.com/prometheus/procfs v0.11.0 h1:5EAgkfkMl659uZPbe9AS2N68a7Cc1TJbPEuGzFuR
github.com/prometheus/procfs v0.11.0/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM=
github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ=
@@ -202,6 +215,8 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.bug.st/serial v1.6.1 h1:VSSWmUxlj1T/YlRo2J104Zv3wJFrjHIl/T3NeruWAHY=
go.bug.st/serial v1.6.1/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
@@ -211,6 +226,7 @@ go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
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/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.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
@@ -308,6 +324,8 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI=
golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -346,6 +364,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -510,6 +530,7 @@ google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
@@ -527,3 +548,5 @@ honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
tinygo.org/x/drivers v0.26.0 h1:7KSIYssX0ki0dd7yBYkVZWSG0kt8vrZNS0It73TymcA=
tinygo.org/x/drivers v0.26.0/go.mod h1:X7utcg3yfFUFuKLOMTZD56eztXMjpkcf8OHldfTBsjw=

View File

@@ -10,6 +10,7 @@ import (
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/xvzf/computeblade-agent/pkg/fancontroller"
"github.com/xvzf/computeblade-agent/pkg/hal"
"github.com/xvzf/computeblade-agent/pkg/hal/led"
"github.com/xvzf/computeblade-agent/pkg/ledengine"
"github.com/xvzf/computeblade-agent/pkg/log"
"go.uber.org/zap"
@@ -63,12 +64,12 @@ func (e Event) String() string {
type ComputeBladeAgentConfig struct {
// IdleLedColor is the color of the edge LED when the blade is idle mode
IdleLedColor hal.LedColor `mapstructure:"idle_led_color"`
IdleLedColor led.Color `mapstructure:"idle_led_color"`
// IdentifyLedColor is the color of the edge LED when the blade is in identify mode
IdentifyLedColor hal.LedColor `mapstructure:"identify_led_color"`
IdentifyLedColor led.Color `mapstructure:"identify_led_color"`
// CriticalLedColor is the color of the top(!) LED when the blade is in critical mode.
// In the circumstance when >1 blades are in critical mode, the identidy function can be used to find the right blade
CriticalLedColor hal.LedColor `mapstructure:"critical_led_color"`
CriticalLedColor led.Color `mapstructure:"critical_led_color"`
// StealthModeEnabled indicates whether stealth mode is enabled
StealthModeEnabled bool `mapstructure:"stealth_mode"`
@@ -80,6 +81,8 @@ type ComputeBladeAgentConfig struct {
FanSpeed *fancontroller.FanOverrideOpts `mapstructure:"fan_speed"`
// FanControllerConfig is the configuration of the fan controller
FanControllerConfig fancontroller.FanControllerConfig `mapstructure:"fan_controller"`
ComputeBladeHalOpts hal.ComputeBladeHalOpts `mapstructure:"hal"`
}
// ComputeBladeAgent implements the core-logic of the agent. It is responsible for handling events and interfacing with the hardware.
@@ -110,13 +113,11 @@ type computeBladeAgentImpl struct {
eventChan chan Event
}
func NewComputeBladeAgent(opts ComputeBladeAgentConfig) (ComputeBladeAgent, error) {
func NewComputeBladeAgent(ctx context.Context, opts ComputeBladeAgentConfig) (ComputeBladeAgent, error) {
var err error
// blade, err := hal.NewCm4Hal(hal.ComputeBladeHalOpts{
blade, err := hal.NewCm4Hal(hal.ComputeBladeHalOpts{
FanUnit: hal.FanUnitStandard, // FIXME: support smart fan unit
})
blade, err := hal.NewCm4Hal(ctx, opts.ComputeBladeHalOpts)
if err != nil {
return nil, err
}
@@ -171,6 +172,17 @@ func (a *computeBladeAgentImpl) Run(origCtx context.Context) error {
return err
}
// Run HAL
wg.Add(1)
go func() {
defer wg.Done()
log.FromContext(ctx).Info("Starting HAL")
if err := a.blade.Run(ctx); err != nil && err != context.Canceled {
log.FromContext(ctx).Error("HAL failed", zap.Error(err))
cancelCtx(err)
}
}()
// Start edge button event handler
wg.Add(1)
go func() {
@@ -258,12 +270,15 @@ func (a *computeBladeAgentImpl) cleanup(ctx context.Context) {
if err := a.blade.SetFanSpeed(100); err != nil {
log.FromContext(ctx).Error("Failed to set fan speed to 100%", zap.Error(err))
}
if err := a.blade.SetLed(hal.LedEdge, hal.LedColor{}); err != nil {
if err := a.blade.SetLed(hal.LedEdge, led.Color{}); err != nil {
log.FromContext(ctx).Error("Failed to set edge LED to off", zap.Error(err))
}
if err := a.blade.SetLed(hal.LedTop, hal.LedColor{}); err != nil {
if err := a.blade.SetLed(hal.LedTop, led.Color{}); err != nil {
log.FromContext(ctx).Error("Failed to set edge LED to off", zap.Error(err))
}
if err := a.Close(); err != nil {
log.FromContext(ctx).Error("Failed to close blade", zap.Error(err))
}
}
func (a *computeBladeAgentImpl) handleEvent(ctx context.Context, event Event) error {
@@ -306,7 +321,7 @@ func (a *computeBladeAgentImpl) handleEvent(ctx context.Context, event Event) er
func (a *computeBladeAgentImpl) handleIdentifyActive(ctx context.Context) error {
log.FromContext(ctx).Info("Identify active")
return a.edgeLedEngine.SetPattern(ledengine.NewBurstPattern(hal.LedColor{}, a.opts.IdentifyLedColor))
return a.edgeLedEngine.SetPattern(ledengine.NewBurstPattern(led.Color{}, a.opts.IdentifyLedColor))
}
func (a *computeBladeAgentImpl) handleIdentifyConfirm(ctx context.Context) error {
@@ -325,7 +340,7 @@ func (a *computeBladeAgentImpl) handleCriticalActive(ctx context.Context) error
// Set critical pattern for top LED
setPatternTopLedErr := a.topLedEngine.SetPattern(
ledengine.NewSlowBlinkPattern(hal.LedColor{}, a.opts.CriticalLedColor),
ledengine.NewSlowBlinkPattern(led.Color{}, a.opts.CriticalLedColor),
)
// Combine errors, but don't stop execution flow for now
return errors.Join(setStealthModeError, setPatternTopLedErr)
@@ -342,7 +357,7 @@ func (a *computeBladeAgentImpl) handleCriticalReset(ctx context.Context) error {
}
// Set top LED off
if err := a.topLedEngine.SetPattern(ledengine.NewStaticPattern(hal.LedColor{})); err != nil {
if err := a.topLedEngine.SetPattern(ledengine.NewStaticPattern(led.Color{})); err != nil {
return err
}
@@ -356,7 +371,7 @@ func (a *computeBladeAgentImpl) Close() error {
// runTopLedEngine runs the top LED engine
func (a *computeBladeAgentImpl) runTopLedEngine(ctx context.Context) error {
// FIXME the top LED is only used to indicate emergency situations
err := a.topLedEngine.SetPattern(ledengine.NewStaticPattern(hal.LedColor{}))
err := a.topLedEngine.SetPattern(ledengine.NewStaticPattern(led.Color{}))
if err != nil {
return err
}

102
pkg/eventbus/eventbus.go Normal file
View File

@@ -0,0 +1,102 @@
package eventbus
import (
"sync"
)
// EventBus is a simple event bus with topic-based publish/subscribe.
// This is, by no means, a performant or complete implementation but for the scope of this project more than sufficient
type EventBus interface {
Publish(topic string, message any)
Subscribe(topic string, bufSize int, filter func(any) bool) Subscriber
}
type Subscriber interface {
C() <-chan any
Unsubscribe()
}
type eventBus struct {
subscribers map[string]map[*subscriber]func(any) bool
mu sync.Mutex
}
type subscriber struct {
mu sync.Mutex
ch chan any
closed bool
}
func MatchAll(any) bool {
return true
}
// New returns an initialized EventBus.
func New() EventBus {
return &eventBus{
subscribers: make(map[string]map[*subscriber]func(any) bool),
}
}
// Publish a message to a topic (best-effort). Subscribers with a full receive queue are dropped.
func (eb *eventBus) Publish(topic string, message any) {
eb.mu.Lock()
defer eb.mu.Unlock()
if eb.subscribers[topic] == nil {
return
}
if subs, ok := eb.subscribers[topic]; ok {
for sub, filter := range subs {
sub.mu.Lock()
// Clean up closed subscribers
if sub.closed {
delete(eb.subscribers[topic], sub)
continue
}
if filter(message) {
// Try to send message, but don't block
select {
case sub.ch <- message:
default:
}
}
sub.mu.Unlock()
}
}
}
// Subscribe to a topic with a filter function. Returns a channel with given buffer size.
func (eb *eventBus) Subscribe(topic string, bufSize int, filter func(any) bool) Subscriber {
eb.mu.Lock()
defer eb.mu.Unlock()
ch := make(chan any, bufSize)
sub := &subscriber{
ch: ch,
closed: false,
}
if _, ok := eb.subscribers[topic]; !ok {
eb.subscribers[topic] = make(map[*subscriber]func(any) bool)
}
eb.subscribers[topic][sub] = filter
return sub
}
func (s *subscriber) C() <-chan any {
return s.ch
}
func (s *subscriber) Unsubscribe() {
s.mu.Lock()
defer s.mu.Unlock()
close(s.ch)
s.closed = true
}

View File

@@ -0,0 +1,76 @@
package eventbus_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/xvzf/computeblade-agent/pkg/eventbus"
)
func TestEventBusManySubscribers(t *testing.T) {
eb := eventbus.New()
// Create a channel and subscribe to a topic without a filter
sub0 := eb.Subscribe("topic0", 2, eventbus.MatchAll)
assert.Equal(t, cap(sub0.C()), 2)
assert.Equal(t, len(sub0.C()), 0)
defer sub0.Unsubscribe()
// Create a channel and subscribe to a topic with a filter
sub1 := eb.Subscribe("topic0", 2, func(msg any) bool {
return msg.(int) > 5
})
assert.Equal(t, cap(sub1.C()), 2)
assert.Equal(t, len(sub1.C()), 0)
defer sub1.Unsubscribe()
// Create a channel and subscribe to another topic
sub2 := eb.Subscribe("topic1", 1, eventbus.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)
assert.Equal(t, cap(sub3.C()), 0)
assert.Equal(t, len(sub3.C()), 0)
defer sub3.Unsubscribe()
// Publish some messages
eb.Publish("topic0", 10)
eb.Publish("topic0", 4)
eb.Publish("topic1", "Hello, World!")
// Assert received messages
assert.Equal(t, len(sub0.C()), 2)
assert.Equal(t, <-sub0.C(), 10)
assert.Equal(t, <-sub0.C(), 4)
assert.Equal(t, len(sub1.C()), 1)
assert.Equal(t, <-sub1.C(), 10)
assert.Equal(t, len(sub2.C()), 1)
assert.Equal(t, <-sub2.C(), "Hello, World!")
// sub3 has no buffer, so it should be empty as there's been no consumer at time of publishing
assert.Equal(t, len(sub3.C()), 0)
}
func TestUnsubscribe(t *testing.T) {
eb := eventbus.New()
// Create a channel and subscribe to a topic
sub := eb.Subscribe("topic", 2, eventbus.MatchAll)
// Unsubscribe from the topic
sub.Unsubscribe()
// Try to publish a message after unsubscribing
eb.Publish("topic", "This message should not be received")
// Assert that the channel is closed
_, ok := <-sub.C()
assert.False(t, ok, "Unsubscribed channel should be closed")
}

View File

@@ -0,0 +1,49 @@
//go:build !tinygo
package hal_test
import (
"context"
"log"
"github.com/xvzf/computeblade-agent/pkg/hal"
"github.com/xvzf/computeblade-agent/pkg/hal/led"
)
func ExampleNewSmartFanUnit() {
ctx := context.Background()
client, err := hal.NewSmartFanUnit("/dev/tty.usbmodem11102")
if err != nil {
panic(err)
}
go func() {
err := client.Run(ctx)
if err != nil {
panic(err)
}
}()
// Set LED color for the blade to red
err = client.SetLed(ctx, led.Color{Red: 100, Green: 0, Blue: 0})
if err != nil {
panic(err)
}
// Set fanspeed to 20%
err = client.SetFanSpeedPercent(ctx, 20)
if err != nil {
panic(err)
}
tmp, err := client.AirFlowTemperature(ctx)
if err != nil {
panic(err)
}
log.Println("AirflowTemp", tmp)
rpm, err := client.FanSpeedRPM(ctx)
if err != nil {
panic(err)
}
log.Println("RPM", rpm)
}

View File

@@ -3,62 +3,13 @@ package hal
import (
"context"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/xvzf/computeblade-agent/pkg/hal/led"
)
type FanUnit uint8
type FanUnitKind uint8
type ComputeModule uint8
type PowerStatus uint8
var (
fanSpeedTargetPercent = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "fan_speed_target_percent",
Help: "Target fanspeed in percent",
})
fanSpeed = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "fan_speed",
Help: "Fan speed in RPM",
})
socTemperature = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "soc_temperature",
Help: "SoC temperature in °C",
})
computeModule = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "compute_modul_present",
Help: "Compute module type",
}, []string{"type"})
ledColorChangeEventCount = promauto.NewCounter(prometheus.CounterOpts{
Namespace: "computeblade",
Name: "led_color_change_event_count",
Help: "Led color change event_count",
})
powerStatus = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "power_status",
Help: "Power status of the blade",
}, []string{"type"})
stealthModeEnabled = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "stealth_mode_enabled",
Help: "Stealth mode enabled",
})
fanUnit = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "fan_unit",
Help: "Fan unit",
}, []string{"type"})
edgeButtonEventCount = promauto.NewCounter(prometheus.CounterOpts{
Namespace: "computeblade",
Name: "edge_button_event_count",
Help: "Number of edge button presses",
})
)
func (p PowerStatus) String() string {
switch p {
case PowerPoe802at:
@@ -71,8 +22,9 @@ func (p PowerStatus) String() string {
}
const (
FanUnitStandard = iota
FanUnitSmart
FanUnitKindStandard = iota
FanUnitKindStandardNoRPM
FanUnitKindSmart
)
const (
@@ -85,18 +37,15 @@ const (
LedEdge
)
type LedColor struct {
Red uint8 `mapstructure:"red"`
Green uint8 `mapstructure:"green"`
Blue uint8 `mapstructure:"blue"`
}
type ComputeBladeHalOpts struct {
FanUnit FanUnit
RpmReportingStandardFanUnit bool `mapstructure:"rpm_reporting_standard_fan_unit"`
}
// ComputeBladeHal abstracts hardware details of the Compute Blade and provides a simple interface
type ComputeBladeHal interface {
// Run starts background tasks and returns when the context is cancelled or an error occurs
Run(ctx context.Context) error
// Close closes the ComputeBladeHal
Close() error
// SetFanSpeed sets the fan speed in percent
SetFanSpeed(speed uint8) error
@@ -105,7 +54,7 @@ type ComputeBladeHal interface {
// 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(idx uint, color LedColor) error
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
@@ -113,3 +62,31 @@ type ComputeBladeHal interface {
// GetEdgeButtonPressChan returns a channel emitting edge button press events
WaitForEdgeButtonPress(ctx context.Context) error
}
// FanUnit abstracts the fan unit
type FanUnit interface {
// Kind returns the kind of the fan FanUnit
Kind() FanUnitKind
// Run the client with event loop
Run(context.Context) error
// SetFanSpeedPercent sets the fan speed in percent.
SetFanSpeedPercent(context.Context, uint8) error
// SetLed sets the LED color. Noop if the LED is not available.
SetLed(context.Context, led.Color) error
// FanSpeedRPM returns the current fan speed in rotations per minute.
FanSpeedRPM(context.Context) (float64, error)
// WaitForButtonPress blocks until the button is pressed. Noop if the button is not available.
WaitForButtonPress(context.Context) error
// AirFlowTemperature returns the temperature of the air flow. Noop if the sensor is not available.
AirFlowTemperature(context.Context) (float32, error)
Close() error
}

View File

@@ -1,4 +1,4 @@
//go:build linux
//go:build linux && !tinygo
package hal
@@ -16,6 +16,10 @@ import (
"github.com/warthog618/gpiod"
"github.com/warthog618/gpiod/device/rpi"
"github.com/xvzf/computeblade-agent/pkg/hal/led"
"github.com/xvzf/computeblade-agent/pkg/log"
"go.uber.org/zap"
"golang.org/x/sync/errgroup"
)
const (
@@ -52,6 +56,8 @@ const (
bcm2711DebounceInterval = 100 * time.Millisecond
bcm2711ThermalZonePath = "/sys/class/thermal/thermal_zone0/temp"
smartFanUnitDev = "/dev/ttyAMA5" // UART5
)
type bcm2711 struct {
@@ -73,7 +79,7 @@ type bcm2711 struct {
gpioChip0 *gpiod.Chip
// Save LED colors so the pixels can be updated individually
leds [2]LedColor
leds [2]led.Color
// Stealth mode output
stealthModeLine *gpiod.Line
@@ -86,13 +92,11 @@ type bcm2711 struct {
// PoE detection input
poeLine *gpiod.Line
// Fan tach input
fanEdgeLine *gpiod.Line
lastFanEdgeEvent *gpiod.LineEvent
fanRpm float64
// Fan unit
fanUnit FanUnit
}
func NewCm4Hal(opts ComputeBladeHalOpts) (ComputeBladeHal, error) {
func NewCm4Hal(ctx context.Context, opts ComputeBladeHalOpts) (ComputeBladeHal, error) {
// /dev/gpiomem doesn't allow complex operations for PWM fan control or WS281x
devmem, err := os.OpenFile("/dev/mem", os.O_RDWR|os.O_SYNC, os.ModePerm)
if err != nil {
@@ -134,83 +138,32 @@ func NewCm4Hal(opts ComputeBladeHalOpts) (ComputeBladeHal, error) {
computeModule.WithLabelValues("cm4").Set(1)
return bcm, bcm.setup()
log.FromContext(ctx).Info("starting hal setup", zap.String("hal", "bcm2711"))
err = bcm.setup(ctx)
if err != nil {
return nil, err
}
return bcm, nil
}
// Close cleans all memory mappings
func (bcm *bcm2711) Close() error {
errs := errors.Join(
bcm.fanUnit.Close(),
syscall.Munmap(bcm.gpioMem8),
syscall.Munmap(bcm.pwmMem8),
syscall.Munmap(bcm.clkMem8),
bcm.devmem.Close(),
bcm.gpioChip0.Close(),
bcm.edgeButtonLine.Close(),
bcm.poeLine.Close(),
bcm.stealthModeLine.Close(),
)
if bcm.fanEdgeLine != nil {
return errors.Join(errs, bcm.fanEdgeLine.Close())
}
return errs
}
// handleFanEdge handles an edge event on the fan tach input for the standard fan unite.
// Exponential moving average is used to smooth out the fan speed.
func (bcm *bcm2711) handleFanEdge(evt gpiod.LineEvent) {
// Ensure we're always storing the last event
defer func() {
bcm.lastFanEdgeEvent = &evt
}()
// First event, we cannot extrapolate the fan speed yet
if bcm.lastFanEdgeEvent == nil {
return
}
// Calculate time delta between events
delta := evt.Timestamp - bcm.lastFanEdgeEvent.Timestamp
ticksPerSecond := 1000.0 / float64(delta.Milliseconds())
rpm := (ticksPerSecond * 60.0) / 2.0 // 2 ticks per revolution
// Simple moving average to smooth out the fan speed
bcm.fanRpm = (rpm * 0.1) + (bcm.fanRpm * 0.9)
fanSpeed.Set(bcm.fanRpm)
}
func (bcm *bcm2711) handleEdgeButtonEdge(evt gpiod.LineEvent) {
// Despite the debounce, 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{}{}:
go func() {
// Manually debounce the button
<-bcm.edgeButtonDebounceChan
time.Sleep(bcm2711DebounceInterval)
edgeButtonEventCount.Inc()
close(bcm.edgeButtonWatchChan)
bcm.edgeButtonWatchChan = make(chan struct{})
}()
default:
// noop
return
}
}
// WaitForEdgeButtonPress blocks until the edge button has been pressed
func (bcm *bcm2711) WaitForEdgeButtonPress(ctx context.Context) error {
// Either wait for the context to be cancelled or the edge button to be pressed
select {
case <-ctx.Done():
return ctx.Err()
case <-bcm.edgeButtonWatchChan:
return nil
}
}
// Init initialises GPIOs and sets sane defaults
func (bcm *bcm2711) setup() error {
func (bcm *bcm2711) setup(ctx context.Context) error {
var err error = nil
// Register edge event handler for edge button
@@ -233,29 +186,97 @@ func (bcm *bcm2711) setup() error {
return err
}
// standard fan unit
if bcm.opts.FanUnit == FanUnitStandard {
fanUnit.WithLabelValues("standard").Set(1)
// FAN PWM output for standard fan unit (GPIO 12)
// -> bcm2711RegGpfsel1 8:6, alt0
bcm.gpioMem[bcm2711RegGpfsel1] = (bcm.gpioMem[bcm2711RegGpfsel1] &^ (0b111 << 6)) | (0b100 << 6)
// Register edge event handler for fan tach input
bcm.fanEdgeLine, err = bcm.gpioChip0.RequestLine(
rpi.GPIO13,
gpiod.WithEventHandler(bcm.handleFanEdge),
gpiod.WithFallingEdge,
gpiod.WithPullUp,
)
// Setup correct fan unit
log.FromContext(ctx).Info("detecting fan unit")
detectCtx, cancel := context.WithTimeout(ctx, 3*time.Second) // temp events are sent every 2 seconds
defer cancel()
if smartFanUnitPresent, err := SmartFanUnitPresent(detectCtx, smartFanUnitDev); err == nil && smartFanUnitPresent {
log.FromContext(ctx).Error("detected smart fan unit")
bcm.fanUnit, err = NewSmartFanUnit(smartFanUnitDev)
if err != nil {
return err
}
} else {
log.FromContext(ctx).Info("no smart fan unit detected, assuming standard fan unit", zap.Error(err))
// FAN PWM output for standard fan unit (GPIO 12)
// -> bcm2711RegGpfsel1 8:6, alt0
bcm.gpioMem[bcm2711RegGpfsel1] = (bcm.gpioMem[bcm2711RegGpfsel1] &^ (0b111 << 6)) | (0b100 << 6)
bcm.fanUnit = &standardFanUnitBcm2711{
GpioChip0: bcm.gpioChip0,
DisableRPMreporting: !bcm.opts.RpmReportingStandardFanUnit,
SetFanSpeedPwmFunc: func(speed uint8) error {
bcm.setFanSpeedPWM(speed)
return nil
},
}
}
return err
return nil
}
func (bcm2711 *bcm2711) GetFanRPM() (float64, error) {
return bcm2711.fanRpm, nil
func (bcm *bcm2711) Run(parentCtx context.Context) error {
ctx, cancel := context.WithCancel(parentCtx)
defer cancel()
group := errgroup.Group{}
group.Go(func() error {
defer cancel()
return bcm.fanUnit.Run(ctx)
})
return group.Wait()
}
func (bcm *bcm2711) handleEdgeButtonEdge(evt gpiod.LineEvent) {
// Despite the debounce, 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{}{}:
go func() {
// Manually debounce the button
<-bcm.edgeButtonDebounceChan
time.Sleep(bcm2711DebounceInterval)
edgeButtonEventCount.Inc()
close(bcm.edgeButtonWatchChan)
bcm.edgeButtonWatchChan = make(chan struct{})
}()
default:
// noop
return
}
}
// WaitForEdgeButtonPress blocks until the edge button has been pressed
func (bcm *bcm2711) WaitForEdgeButtonPress(parentCtx context.Context) error {
ctx, cancel := context.WithCancel(parentCtx)
defer cancel()
fanUnitChan := make(chan struct{})
go func() {
err := bcm.fanUnit.WaitForButtonPress(ctx)
if err != nil && err != context.Canceled {
log.FromContext(ctx).Error("failed to wait for button press", zap.Error(err))
} else {
close(fanUnitChan)
}
}()
// Either wait for the context to be cancelled or the edge button to be pressed
select {
case <-ctx.Done():
return ctx.Err()
case <-bcm.edgeButtonWatchChan:
return nil
case <-fanUnitChan:
return nil
}
}
func (bcm *bcm2711) GetFanRPM() (float64, error) {
rpm, err := bcm.fanUnit.FanSpeedRPM(context.TODO())
return float64(rpm), err
}
func (bcm *bcm2711) GetPowerStatus() (PowerStatus, error) {
@@ -316,9 +337,7 @@ func (bcm *bcm2711) setPwm0Freq(targetFrequency uint64) error {
// SetFanSpeed sets the fanspeed of a blade in percent (standard fan unit)
func (bcm *bcm2711) SetFanSpeed(speed uint8) error {
fanSpeedTargetPercent.Set(float64(speed))
bcm.setFanSpeedPWM(speed)
return nil
return bcm.fanUnit.SetFanSpeedPercent(context.TODO(), speed)
}
func (bcm *bcm2711) setFanSpeedPWM(speed uint8) {
@@ -381,11 +400,16 @@ func serializePwmDataFrame(data uint8) uint32 {
return result
}
func (bcm *bcm2711) SetLed(idx uint, color LedColor) error {
func (bcm *bcm2711) SetLed(idx uint, color led.Color) error {
if idx >= 2 {
return fmt.Errorf("invalid led index %d, supported: [0, 1]", idx)
}
// Update the fan unit LED if the index is the same as the fan unit LED index
if idx == LedEdge {
bcm.fanUnit.SetLed(context.TODO(), color)
}
bcm.leds[idx] = color
return bcm.updateLEDs()

View File

@@ -6,6 +6,7 @@ import (
"context"
"time"
"github.com/xvzf/computeblade-agent/pkg/hal/led"
"go.uber.org/zap"
)
@@ -17,18 +18,25 @@ type SimulatedHal struct {
logger *zap.Logger
}
func NewCm4Hal(_ ComputeBladeHalOpts) (ComputeBladeHal, error) {
func NewCm4Hal(_ context.Context, _ ComputeBladeHalOpts) (ComputeBladeHal, error) {
logger := zap.L().Named("hal").Named("simulated-cm4")
logger.Warn("Using simulated hal")
computeModule.WithLabelValues("simulated").Set(1)
fanUnit.WithLabelValues("simulated").Set(1)
socTemperature.Set(42)
return &SimulatedHal{
logger: logger,
}, nil
}
func (m *SimulatedHal) Run(ctx context.Context) error {
<-ctx.Done()
return ctx.Err()
}
func (m *SimulatedHal) Close() error {
return nil
}
@@ -71,7 +79,7 @@ func (m *SimulatedHal) WaitForEdgeButtonPress(ctx context.Context) error {
}
}
func (m *SimulatedHal) SetLed(idx uint, color LedColor) error {
func (m *SimulatedHal) SetLed(idx uint, color led.Color) error {
ledColorChangeEventCount.Inc()
m.logger.Info("SetLed", zap.Uint("idx", idx), zap.Any("color", color))
return nil

View File

@@ -0,0 +1,100 @@
//go:build linux && !tinygo
package hal
import (
"context"
"math"
"github.com/warthog618/gpiod"
"github.com/warthog618/gpiod/device/rpi"
"github.com/xvzf/computeblade-agent/pkg/hal/led"
)
type standardFanUnitBcm2711 struct {
GpioChip0 *gpiod.Chip
SetFanSpeedPwmFunc func(speed uint8) error
DisableRPMreporting bool
// Fan tach input
fanEdgeLine *gpiod.Line
lastFanEdgeEvent *gpiod.LineEvent
fanRpm float64
}
func (fu standardFanUnitBcm2711) Kind() FanUnitKind {
if fu.DisableRPMreporting {
return FanUnitKindStandardNoRPM
}
return FanUnitKindStandard
}
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 {
fu.fanEdgeLine, err = fu.GpioChip0.RequestLine(
rpi.GPIO13,
gpiod.WithEventHandler(fu.handleFanEdge),
gpiod.WithFallingEdge,
gpiod.WithPullUp,
)
if err != nil {
return err
}
defer fu.fanEdgeLine.Close()
}
<-ctx.Done()
return ctx.Err()
}
// handleFanEdge handles an edge event on the fan tach 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
defer func() {
fu.lastFanEdgeEvent = &evt
}()
// First event, we cannot extrapolate the fan speed yet
if fu.lastFanEdgeEvent == nil {
return
}
// Calculate time delta between events
delta := evt.Timestamp - fu.lastFanEdgeEvent.Timestamp
ticksPerSecond := 1000.0 / float64(delta.Milliseconds())
rpm := (ticksPerSecond * 60.0) / 2.0 // 2 ticks per revolution
// Simple moving average to smooth out the fan speed
fu.fanRpm = (rpm * 0.1) + (fu.fanRpm * 0.9)
fanSpeed.Set(fu.fanRpm)
}
func (fu *standardFanUnitBcm2711) SetFanSpeedPercent(_ context.Context, percent uint8) error {
return fu.SetFanSpeedPwmFunc(percent)
}
func (fu *standardFanUnitBcm2711) SetLed(_ context.Context, _ led.Color) error {
return nil
}
func (fu *standardFanUnitBcm2711) FanSpeedRPM(_ context.Context) (float64, error) {
return fu.fanRpm, nil
}
func (fu *standardFanUnitBcm2711) WaitForButtonPress(ctx context.Context) error {
<-ctx.Done()
return ctx.Err()
}
func (fu *standardFanUnitBcm2711) AirFlowTemperature(_ context.Context) (float32, error) {
return -1 * math.MaxFloat32, nil
}
func (fu *standardFanUnitBcm2711) Close() error {
return nil
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"github.com/stretchr/testify/mock"
"github.com/xvzf/computeblade-agent/pkg/hal/led"
)
// fails if ComputeBladeHalMock does not implement ComputeBladeHal
@@ -14,6 +15,11 @@ type ComputeBladeHalMock struct {
mock.Mock
}
func (m *ComputeBladeHalMock) Run(ctx context.Context) error {
args := m.Called(ctx)
return args.Error(0)
}
func (m *ComputeBladeHalMock) Close() error {
args := m.Called()
return args.Error(0)
@@ -44,7 +50,7 @@ func (m *ComputeBladeHalMock) WaitForEdgeButtonPress(ctx context.Context) error
return args.Error(0)
}
func (m *ComputeBladeHalMock) SetLed(idx uint, color LedColor) error {
func (m *ComputeBladeHalMock) SetLed(idx uint, color led.Color) error {
args := m.Called(idx, color)
return args.Error(0)
}

7
pkg/hal/led/types.go Normal file
View File

@@ -0,0 +1,7 @@
package led
type Color struct {
Red uint8 `mapstructure:"red"`
Green uint8 `mapstructure:"green"`
Blue uint8 `mapstructure:"blue"`
}

60
pkg/hal/metrics.go Normal file
View File

@@ -0,0 +1,60 @@
//go:build !tinygo
package hal
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
fanSpeedTargetPercent = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "fan_speed_target_percent",
Help: "Target fanspeed in percent",
})
fanSpeed = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "fan_speed",
Help: "Fan speed in RPM",
})
socTemperature = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "soc_temperature",
Help: "SoC temperature in °C",
})
airFlowTemperature = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "airflow_temperature",
Help: "airflow temperature in °C",
})
computeModule = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "compute_modul_present",
Help: "Compute module type",
}, []string{"type"})
ledColorChangeEventCount = promauto.NewCounter(prometheus.CounterOpts{
Namespace: "computeblade",
Name: "led_color_change_event_count",
Help: "Led color change event_count",
})
powerStatus = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "power_status",
Help: "Power status of the blade",
}, []string{"type"})
stealthModeEnabled = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "stealth_mode_enabled",
Help: "Stealth mode enabled",
})
fanUnit = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "fan_unit",
Help: "Fan unit",
}, []string{"type"})
edgeButtonEventCount = promauto.NewCounter(prometheus.CounterOpts{
Namespace: "computeblade",
Name: "edge_button_event_count",
Help: "Number of edge button presses",
})
)

204
pkg/hal/smartfanunit.go Normal file
View File

@@ -0,0 +1,204 @@
//go:build !tinygo
package hal
import (
"context"
"errors"
"io"
"sync"
"github.com/xvzf/computeblade-agent/pkg/eventbus"
"github.com/xvzf/computeblade-agent/pkg/hal/led"
"github.com/xvzf/computeblade-agent/pkg/log"
"github.com/xvzf/computeblade-agent/pkg/smartfanunit"
"github.com/xvzf/computeblade-agent/pkg/smartfanunit/proto"
"go.bug.st/serial"
"go.uber.org/zap"
"golang.org/x/sync/errgroup"
)
func SmartFanUnitPresent(ctx context.Context, portName string) (bool, error) {
// Open the serial port.
log.FromContext(ctx).Warn("Opening serial port")
rwc, err := serial.Open(portName, &serial.Mode{
BaudRate: smartfanunit.Baudrate,
})
if err != nil {
return false, err
}
log.FromContext(ctx).Warn("Opened serial port")
defer rwc.Close()
// Close reader after context is done
go func() {
<-ctx.Done()
log.FromContext(ctx).Warn("Closing serial port")
rwc.Close()
}()
// read byte after byte, matching it to the SOF header used by the smart fan unit protocol.
// -> if that's present, we have a smart fanunit connected.
for {
b := make([]byte, 1)
log.FromContext(ctx).Info("Waiting for next byte from serial port")
_, err := rwc.Read(b)
if err != nil {
return false, err
}
if b[0] == proto.SOF {
return true, nil
}
}
}
func NewSmartFanUnit(portName string) (FanUnit, error) {
// Open the serial port.
rwc, err := serial.Open(portName, &serial.Mode{
BaudRate: smartfanunit.Baudrate,
})
if err != nil {
return nil, err
}
return &smartFanUnit{
rwc: rwc,
eb: eventbus.New(),
}, nil
}
var ErrCommunicationFailed = errors.New("communication failed")
const (
inboundTopic = "smartfanunit:inbound"
outboundTopic = "smartfanunit:outbound"
)
type smartFanUnit struct {
rwc io.ReadWriteCloser
mu sync.Mutex // write mutex
speed smartfanunit.FanSpeedRPMPacket
airflow smartfanunit.AirFlowTemperaturePacket
eb eventbus.EventBus
}
func (fuc *smartFanUnit) Kind() FanUnitKind {
return FanUnitKindSmart
}
// Run the client with event loop
func (fuc *smartFanUnit) Run(parentCtx context.Context) error {
fanUnit.WithLabelValues("smart").Set(1)
ctx, cancel := context.WithCancelCause(parentCtx)
defer cancel(nil)
wg := errgroup.Group{}
// Start read loop
wg.Go(func() error {
for {
select {
case <-ctx.Done():
return nil
default:
}
pkt, err := proto.ReadPacket(ctx, fuc.rwc)
if err != nil {
log.FromContext(ctx).Error("Failed to read packet from serial port", zap.Error(err))
continue
}
fuc.eb.Publish(inboundTopic, pkt)
}
})
// Subscribe to fan speed updates
wg.Go(func() error {
sub := fuc.eb.Subscribe(inboundTopic, 1, smartfanunit.MatchCmd(smartfanunit.NotifyFanSpeedRPM))
defer sub.Unsubscribe()
for {
select {
case <-ctx.Done():
return nil
case pktAny := <-sub.C():
rawPkt := pktAny.(proto.Packet)
if err := fuc.speed.FromPacket(rawPkt); err != nil && err != proto.ErrChecksumMismatch {
return err
}
fanSpeed.Set(float64(fuc.speed.RPM))
}
}
})
// Subscribe to air flow temperature updates
wg.Go(func() error {
sub := fuc.eb.Subscribe(inboundTopic, 1, smartfanunit.MatchCmd(smartfanunit.NotifyAirFlowTemperature))
defer sub.Unsubscribe()
for {
select {
case <-ctx.Done():
return nil
case pktAny := <-sub.C():
rawPkt := pktAny.(proto.Packet)
if err := fuc.airflow.FromPacket(rawPkt); err != nil && err != proto.ErrChecksumMismatch {
return err
}
airFlowTemperature.Set(float64(fuc.airflow.Temperature))
}
}
})
return wg.Wait()
}
func (fuc *smartFanUnit) write(ctx context.Context, pktGen smartfanunit.PacketGenerator) error {
fuc.mu.Lock()
defer fuc.mu.Unlock()
return proto.WritePacket(ctx, fuc.rwc, pktGen.Packet())
}
// SetFanSpeedPercent sets the fan speed in percent.
func (fuc *smartFanUnit) SetFanSpeedPercent(ctx context.Context, percent uint8) error {
return fuc.write(ctx, &smartfanunit.SetFanSpeedPercentPacket{Percent: percent})
}
// SetLed sets the LED color.
func (fuc *smartFanUnit) SetLed(ctx context.Context, color led.Color) error {
return fuc.write(ctx, &smartfanunit.SetLEDPacket{Color: color})
}
// FanSpeedRPM returns the current fan speed in rotations per minute.
func (fuc *smartFanUnit) FanSpeedRPM(_ context.Context) (float64, error) {
return float64(fuc.speed.RPM), nil
}
// WaitForButtonPress blocks until the button is pressed.
func (fuc *smartFanUnit) WaitForButtonPress(ctx context.Context) error {
sub := fuc.eb.Subscribe(inboundTopic, 1, smartfanunit.MatchCmd(smartfanunit.NotifyButtonPress))
defer sub.Unsubscribe()
select {
case <-ctx.Done():
return ctx.Err()
case pktAny := <-sub.C():
rawPkt := pktAny.(proto.Packet)
if rawPkt.Command != smartfanunit.NotifyButtonPress {
return errors.New("unexpected packet")
}
}
return nil
}
// AirFlowTemperature returns the temperature of the air flow.
func (fuc *smartFanUnit) AirFlowTemperature(_ context.Context) (float32, error) {
return fuc.airflow.Temperature, nil
}
func (fuc *smartFanUnit) Close() error {
return fuc.rwc.Close()
}

View File

@@ -6,6 +6,7 @@ import (
"time"
"github.com/xvzf/computeblade-agent/pkg/hal"
"github.com/xvzf/computeblade-agent/pkg/hal/led"
"github.com/xvzf/computeblade-agent/pkg/util"
)
@@ -28,9 +29,9 @@ type ledEngineImpl struct {
type BlinkPattern struct {
// BaseColor is the color is the color shown when the pattern starts (-> before the first blink)
BaseColor hal.LedColor
BaseColor led.Color
// ActiveColor is the color shown when the pattern is active (-> during the blink)
ActiveColor hal.LedColor
ActiveColor led.Color
// Delays is a list of delays between changes -> (base) -> 0.5s(active) -> 1s(base) -> 0.5s (active) -> 1s (base)
Delays []time.Duration
}
@@ -39,24 +40,24 @@ func mapBrighnessUint8(brightness float64) uint8 {
return uint8(255.0 * brightness)
}
func LedColorPurple(brightness float64) hal.LedColor {
return hal.LedColor{
func LedColorPurple(brightness float64) led.Color {
return led.Color{
Red: mapBrighnessUint8(brightness),
Green: 0,
Blue: mapBrighnessUint8(brightness),
}
}
func LedColorRed(brightness float64) hal.LedColor {
return hal.LedColor{
func LedColorRed(brightness float64) led.Color {
return led.Color{
Red: mapBrighnessUint8(brightness),
Green: 0,
Blue: 0,
}
}
func LedColorGreen(brightness float64) hal.LedColor {
return hal.LedColor{
func LedColorGreen(brightness float64) led.Color {
return led.Color{
Red: 0,
Green: mapBrighnessUint8(brightness),
Blue: 0,
@@ -64,7 +65,7 @@ func LedColorGreen(brightness float64) hal.LedColor {
}
// NewStaticPattern creates a new static pattern (no color changes)
func NewStaticPattern(color hal.LedColor) BlinkPattern {
func NewStaticPattern(color led.Color) BlinkPattern {
return BlinkPattern{
BaseColor: color,
ActiveColor: color,
@@ -73,23 +74,23 @@ func NewStaticPattern(color hal.LedColor) BlinkPattern {
}
// NewBurstPattern creates a new burst pattern (~1s cycle duration with 3x 50ms bursts)
func NewBurstPattern(baseColor hal.LedColor, burstColor hal.LedColor) BlinkPattern {
func NewBurstPattern(baseColor led.Color, burstColor led.Color) BlinkPattern {
return BlinkPattern{
BaseColor: baseColor,
ActiveColor: burstColor,
Delays: []time.Duration{
750 * time.Millisecond, // 750ms off
50 * time.Millisecond, // 50ms on
50 * time.Millisecond, // 50ms off
50 * time.Millisecond, // 50ms on
50 * time.Millisecond, // 50ms off
50 * time.Millisecond, // 50ms on
500 * time.Millisecond, // 750ms off
100 * time.Millisecond, // 100ms on
100 * time.Millisecond, // 100ms off
100 * time.Millisecond, // 100ms on
100 * time.Millisecond, // 100ms off
100 * time.Millisecond, // 100ms on
},
}
}
// NewSlowBlinkPattern creates a new slow blink pattern (~2s cycle duration with 1s off and 1s on)
func NewSlowBlinkPattern(baseColor hal.LedColor, activeColor hal.LedColor) BlinkPattern {
func NewSlowBlinkPattern(baseColor led.Color, activeColor led.Color) BlinkPattern {
return BlinkPattern{
BaseColor: baseColor,
ActiveColor: activeColor,
@@ -118,8 +119,8 @@ func NewLedEngine(opts LedEngineOpts) *ledEngineImpl {
return &ledEngineImpl{
ledIdx: opts.LedIdx,
hal: opts.Hal,
restart: make(chan struct{}), // restart channel controls cancelation of any pattern
pattern: NewStaticPattern(hal.LedColor{}), // Turn off LEDs by default
restart: make(chan struct{}), // restart channel controls cancelation of any pattern
pattern: NewStaticPattern(led.Color{}), // Turn off LEDs by default
clock: clock,
}
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/xvzf/computeblade-agent/pkg/hal"
"github.com/xvzf/computeblade-agent/pkg/hal/led"
"github.com/xvzf/computeblade-agent/pkg/ledengine"
"github.com/xvzf/computeblade-agent/pkg/util"
)
@@ -18,7 +19,7 @@ func TestNewStaticPattern(t *testing.T) {
t.Parallel()
type args struct {
color hal.LedColor
color led.Color
}
tests := []struct {
name string
@@ -27,19 +28,19 @@ func TestNewStaticPattern(t *testing.T) {
}{
{
"Green",
args{hal.LedColor{Green: 255}},
args{led.Color{Green: 255}},
ledengine.BlinkPattern{
BaseColor: hal.LedColor{Green: 255},
ActiveColor: hal.LedColor{Green: 255},
BaseColor: led.Color{Green: 255},
ActiveColor: led.Color{Green: 255},
Delays: []time.Duration{time.Hour},
},
},
{
"Red",
args{hal.LedColor{Red: 255}},
args{led.Color{Red: 255}},
ledengine.BlinkPattern{
BaseColor: hal.LedColor{Red: 255},
ActiveColor: hal.LedColor{Red: 255},
BaseColor: led.Color{Red: 255},
ActiveColor: led.Color{Red: 255},
Delays: []time.Duration{time.Hour},
},
},
@@ -56,8 +57,8 @@ func TestNewStaticPattern(t *testing.T) {
func TestNewBurstPattern(t *testing.T) {
t.Parallel()
type args struct {
baseColor hal.LedColor
burstColor hal.LedColor
baseColor led.Color
burstColor led.Color
}
tests := []struct {
name string
@@ -67,38 +68,38 @@ func TestNewBurstPattern(t *testing.T) {
{
"Green <-> Red",
args{
baseColor: hal.LedColor{Green: 255},
burstColor: hal.LedColor{Red: 255},
baseColor: led.Color{Green: 255},
burstColor: led.Color{Red: 255},
},
ledengine.BlinkPattern{
BaseColor: hal.LedColor{Green: 255},
ActiveColor: hal.LedColor{Red: 255},
BaseColor: led.Color{Green: 255},
ActiveColor: led.Color{Red: 255},
Delays: []time.Duration{
750 * time.Millisecond,
50 * time.Millisecond,
50 * time.Millisecond,
50 * time.Millisecond,
50 * time.Millisecond,
50 * time.Millisecond,
500 * time.Millisecond, // 750ms off
100 * time.Millisecond, // 100ms on
100 * time.Millisecond, // 100ms off
100 * time.Millisecond, // 100ms on
100 * time.Millisecond, // 100ms off
100 * time.Millisecond, // 100ms on
},
},
},
{
"Green <-> Green (valid, but no visual effect)",
args{
baseColor: hal.LedColor{Green: 255},
burstColor: hal.LedColor{Green: 255},
baseColor: led.Color{Green: 255},
burstColor: led.Color{Green: 255},
},
ledengine.BlinkPattern{
BaseColor: hal.LedColor{Green: 255},
ActiveColor: hal.LedColor{Green: 255},
BaseColor: led.Color{Green: 255},
ActiveColor: led.Color{Green: 255},
Delays: []time.Duration{
750 * time.Millisecond,
50 * time.Millisecond,
50 * time.Millisecond,
50 * time.Millisecond,
50 * time.Millisecond,
50 * time.Millisecond,
500 * time.Millisecond, // 750ms off
100 * time.Millisecond, // 100ms on
100 * time.Millisecond, // 100ms off
100 * time.Millisecond, // 100ms on
100 * time.Millisecond, // 100ms off
100 * time.Millisecond, // 100ms on
},
},
},
@@ -114,8 +115,8 @@ func TestNewBurstPattern(t *testing.T) {
func TestNewSlowBlinkPattern(t *testing.T) {
type args struct {
baseColor hal.LedColor
activeColor hal.LedColor
baseColor led.Color
activeColor led.Color
}
tests := []struct {
name string
@@ -154,8 +155,8 @@ func Test_LedEngine_SetPattern_WhileRunning(t *testing.T) {
clk.On("After", time.Hour).Times(2).Return(clkAfterChan)
cbMock := hal.ComputeBladeHalMock{}
cbMock.On("SetLed", uint(0), hal.LedColor{Green: 0, Blue: 0, Red: 0}).Once().Return(nil)
cbMock.On("SetLed", uint(0), hal.LedColor{Green: 0, Blue: 0, Red: 255}).Once().Return(nil)
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{
Hal: &cbMock,
@@ -182,7 +183,7 @@ func Test_LedEngine_SetPattern_WhileRunning(t *testing.T) {
// Set pattern
t.Log("Setting pattern")
err := engine.SetPattern(ledengine.NewStaticPattern(hal.LedColor{Red: 255}))
err := engine.SetPattern(ledengine.NewStaticPattern(led.Color{Red: 255}))
assert.NoError(t, err)
t.Log("Canceling context")
@@ -201,7 +202,7 @@ func Test_LedEngine_SetPattern_BeforeRun(t *testing.T) {
clk.On("After", time.Hour).Once().Return(clkAfterChan)
cbMock := hal.ComputeBladeHalMock{}
cbMock.On("SetLed", uint(0), hal.LedColor{Green: 0, Blue: 0, Red: 255}).Once().Return(nil)
cbMock.On("SetLed", uint(0), led.Color{Green: 0, Blue: 0, Red: 255}).Once().Return(nil)
opts := ledengine.LedEngineOpts{
Hal: &cbMock,
@@ -212,7 +213,7 @@ func Test_LedEngine_SetPattern_BeforeRun(t *testing.T) {
engine := ledengine.NewLedEngine(opts)
// We want to change the pattern BEFORE the engine is started
t.Log("Setting pattern")
err := engine.SetPattern(ledengine.NewStaticPattern(hal.LedColor{Red: 255}))
err := engine.SetPattern(ledengine.NewStaticPattern(led.Color{Red: 255}))
assert.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
@@ -243,8 +244,8 @@ func Test_LedEngine_SetPattern_SetLedFailureInPattern(t *testing.T) {
clk.On("After", time.Hour).Once().Return(clkAfterChan)
cbMock := hal.ComputeBladeHalMock{}
call0 := cbMock.On("SetLed", uint(0), hal.LedColor{Green: 0, Blue: 0, Red: 0}).Once().Return(nil)
cbMock.On("SetLed", uint(0), hal.LedColor{Green: 0, Blue: 0, Red: 0}).Once().Return(errors.New("failure")).NotBefore(call0)
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{
Hal: &cbMock,

View File

@@ -0,0 +1,125 @@
package smartfanunit
import (
"errors"
"github.com/xvzf/computeblade-agent/pkg/hal/led"
"github.com/xvzf/computeblade-agent/pkg/smartfanunit/proto"
)
const (
// Blade -> FanUnit
CmdSetFanSpeedPercent proto.Command = 0x01
CmdSetLED proto.Command = 0x02
// FanUnit -> Blade, sent in regular intervals
NotifyButtonPress proto.Command = 0xa1
NotifyAirFlowTemperature proto.Command = 0xa2
NotifyFanSpeedRPM proto.Command = 0xa3
)
var ErrInvalidCommand = errors.New("invalid command")
type PacketGenerator interface {
Packet() proto.Packet
}
// SetFanSpeedPercentPacket is sent from the blade to the fan unit to set the fan speed in percent.
type SetFanSpeedPercentPacket struct {
Percent uint8
}
func (p *SetFanSpeedPercentPacket) Packet() proto.Packet {
return proto.Packet{
Command: CmdSetFanSpeedPercent,
Data: proto.Data{p.Percent, 0, 0},
}
}
func (p *SetFanSpeedPercentPacket) FromPacket(packet proto.Packet) error {
if packet.Command != CmdSetFanSpeedPercent {
return ErrInvalidCommand
}
p.Percent = packet.Data[0]
return nil
}
// SetLEDPacket is sent from the blade to the fan unit to set the LED color.
type SetLEDPacket struct {
Color led.Color
}
func (p *SetLEDPacket) Packet() proto.Packet {
return proto.Packet{
Command: CmdSetLED,
Data: proto.Data{p.Color.Blue, p.Color.Green, p.Color.Red},
}
}
func (p *SetLEDPacket) FromPacket(packet proto.Packet) error {
if packet.Command != CmdSetLED {
return ErrInvalidCommand
}
p.Color = led.Color{
Blue: packet.Data[0],
Green: packet.Data[1],
Red: packet.Data[2],
}
return nil
}
// ButtonPressPacket is sent from the fan unit to the blade when the button is pressed.
type ButtonPressPacket struct{}
func (p *ButtonPressPacket) Packet() proto.Packet {
return proto.Packet{
Command: NotifyButtonPress,
Data: proto.Data{},
}
}
func (p *ButtonPressPacket) FromPacket(packet proto.Packet) error {
if packet.Command != NotifyButtonPress {
return ErrInvalidCommand
}
return nil
}
// AirFlowTemperaturePacket is sent from the fan unit to the blade to report the current air flow temperature.
type AirFlowTemperaturePacket struct {
Temperature float32
}
func (p *AirFlowTemperaturePacket) Packet() proto.Packet {
return proto.Packet{
Command: NotifyAirFlowTemperature,
Data: proto.Data(float32To24Bit(p.Temperature)),
}
}
func (p *AirFlowTemperaturePacket) FromPacket(packet proto.Packet) error {
if packet.Command != NotifyAirFlowTemperature {
return ErrInvalidCommand
}
p.Temperature = float32From24Bit(packet.Data)
return nil
}
// FanSpeedRPMPacket is sent from the fan unit to the blade to report the current fan speed in RPM.
type FanSpeedRPMPacket struct {
RPM float32
}
func (p *FanSpeedRPMPacket) Packet() proto.Packet {
return proto.Packet{
Command: NotifyFanSpeedRPM,
Data: float32To24Bit(p.RPM),
}
}
func (p *FanSpeedRPMPacket) FromPacket(packet proto.Packet) error {
if packet.Command != NotifyFanSpeedRPM {
return ErrInvalidCommand
}
p.RPM = float32From24Bit(packet.Data)
return nil
}

View File

@@ -0,0 +1,132 @@
// This is a driver for the EMC2101 fan controller
// Based on https://ww1.microchip.com/downloads/en/DeviceDoc/2101.pdf
package emc2101
import (
"tinygo.org/x/drivers"
)
type emc2101 struct {
Address uint16
bus drivers.I2C
}
// EMC2101 is a driver for the EMC2101 fan controller
type EMC2101 interface {
// Init initializes the EMC2101
Init() error
// InternalTemperature returns the internal temperature of the EMC2101
InternalTemperature() (float32, error)
// ExternalTemperature returns the external temperature of the EMC2101
ExternalTemperature() (float32, error)
// SetFanPercent sets the fan speed as a percentage of max
SetFanPercent(percent uint8) error
// FanRPM returns the current fan speed in RPM
FanRPM() (float32, error)
}
const (
// Address is the default I2C address for the EMC2101
Address = 0x4C
ConfigReg = 0x03
FanConfigReg = 0x4a
FanSpinUpReg = 0x4b
FanSettingReg = 0x4c
FanTachReadingLowReg = 0x46
FanTachReadingHighReg = 0x47
ExternalTempReg = 0x01
InternalTempReg = 0x00
)
func New(bus drivers.I2C) EMC2101 {
return &emc2101{bus: bus, Address: Address}
}
// updateReg updates a register with the given set and clear masks
func (e *emc2101) updateReg(regAddr, setMask, clearMask uint8) error {
buf := make([]uint8, 1)
err := e.bus.Tx(e.Address, []byte{regAddr}, buf)
if err != nil {
return err
}
toWrite := buf[0]
toWrite |= setMask
toWrite &= ^clearMask
if toWrite == buf[0] {
return nil
}
return e.bus.Tx(e.Address, []byte{regAddr, toWrite}, nil)
}
func (e *emc2101) Init() error {
// set pwm mode
// bit 4: 0 = PWM mode
// bit 2: 1 = TACH input
if err := e.updateReg(ConfigReg, (1 << 2), (1 << 4)); err != nil {
return err
}
if err := e.updateReg(FanConfigReg, (1 << 5), 0); err != nil {
return err
}
/*
0x3 0b100
0x4b 0b11111
0x4a 0b100000
0x4a 0b100000
*/
// Configure fan spin up to ignore tach input
// bit 5: 1 = Ignore tach input for spin up procedure
if err := e.updateReg(FanSpinUpReg, 0, (1 << 5)); err != nil {
return err
}
return nil
}
func (e *emc2101) InternalTemperature() (float32, error) {
buf := make([]byte, 1)
if err := e.bus.Tx(e.Address, []byte{InternalTempReg}, buf); err != nil {
return 0, err
}
return float32(buf[0]), nil
}
func (e *emc2101) ExternalTemperature() (float32, error) {
buf := make([]byte, 1)
if err := e.bus.Tx(e.Address, []byte{ExternalTempReg}, buf); err != nil {
return 0, err
}
return float32(buf[0]), nil
}
func (e *emc2101) SetFanPercent(percent uint8) error {
if percent > 100 {
percent = 100
}
val := uint8(uint32(percent) * 63 / 100)
return e.bus.Tx(e.Address, []byte{FanSettingReg, val}, nil)
}
func (e *emc2101) FanRPM() (float32, error) {
high := make([]byte, 1)
low := make([]byte, 1)
err := e.bus.Tx(e.Address, []byte{FanTachReadingHighReg}, high)
if err != nil {
return 0, err
}
err = e.bus.Tx(e.Address, []byte{FanTachReadingLowReg}, low)
if err != nil {
return 0, err
}
var tachCount int = int(high[0])<<8 | int(low[0])
return float32(5400000) / float32(tachCount), nil
}

View File

@@ -0,0 +1,21 @@
package smartfanunit
import "github.com/xvzf/computeblade-agent/pkg/smartfanunit/proto"
func float32To24Bit(val float32) proto.Data {
// Convert float32 to number with 3 bytes (0.1 precision)
tmp := uint32(val * 10)
if tmp > 0xffffff {
tmp = 0xffffff // cap
}
return proto.Data{
uint8((tmp >> 16) & 0xFF),
uint8((tmp >> 8) & 0xFF),
uint8(tmp & 0xFF),
}
}
func float32From24Bit(data proto.Data) float32 {
tmp := uint32(data[0])<<16 | uint32(data[1])<<8 | uint32(data[2])
return float32(tmp) / 10
}

View File

@@ -0,0 +1,34 @@
//go:build !tinygo
package smartfanunit
import (
"fmt"
"testing"
)
func TestFloat32ToAndFrom24Bit(t *testing.T) {
tests := []struct {
input float32
expected float32
}{
{0.0, 0.0},
{1.0, 1.0},
{0.123, 0.1},
{10.0, 10.0},
{100.0, 100.0},
{1677721.5, 1677721.5},
{2000000.0, 1677721.5}, // Should be capped at 0xFFFFFF
}
for _, test := range tests {
t.Run(fmt.Sprintf("Input: %f", test.input), func(t *testing.T) {
data := float32To24Bit(test.input)
result := float32From24Bit(data)
// Check if the result is approximately equal within a small delta
if result < test.expected-0.01 || result > test.expected+0.01 {
t.Errorf("Expected %f, but got %f", test.expected, result)
}
})
}
}

View File

@@ -0,0 +1,148 @@
package proto
import (
"context"
"errors"
"io"
"time"
"tinygo.org/x/drivers"
)
// Simple P2P protocol for communicating over a serial port.
// All commands are 4 bytes long, the first byte is the command, the remaining bytes are data
// This allows encoding of 256 commands, with a payload of 3 bytes each.
// Includes SOF/EOF framing and a checksum. Colliding bytes in the payload are escaped.
var (
ErrChecksumMismatch = errors.New("checksum mismatch")
ErrInvalidFramingByte = errors.New("invalid framing byte")
)
const (
SOF = 0x7E // Start of Frame
ESC = 0x7D // Escape character
XOR = 0x20 // XOR value for escaping
EOF = 0x7F // End of Frame
)
// Command represents the command byte.
type Command uint8
// Data represents the three data bytes.
type Data [3]uint8
// Packet represents a serial packet with command and data.
type Packet struct {
Command Command
Data Data
}
// Checksum calculates the Checksum for a packet.
func (packet *Packet) Checksum() uint8 {
crc := uint8(0)
crc ^= uint8(packet.Command)
for _, d := range packet.Data {
crc ^= d
}
return crc
}
// WritePacket writes a packet to an io.Writer with escaping.
func WritePacket(_ context.Context, w io.Writer, packet Packet) error {
checksum := packet.Checksum()
buf := []uint8{uint8(packet.Command), packet.Data[0], packet.Data[1], packet.Data[2], checksum}
_, err := w.Write([]uint8{SOF})
if err != nil {
return err
}
for _, b := range buf {
if b == SOF || b == EOF || b == ESC {
_, err := w.Write([]uint8{ESC, b ^ XOR})
if err != nil {
return err
}
} else {
_, err := w.Write([]uint8{b})
if err != nil {
return err
}
}
}
_, err = w.Write([]uint8{EOF})
return err
}
// 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{}
started := false
escaped := false
uart, isUart := r.(drivers.UART)
for {
// Check if context is done before reading
select {
case <-ctx.Done():
return Packet{}, ctx.Err()
default:
}
if isUart && uart.Buffered() == 0 {
// Allows TinyGo to switch to other goroutines
time.Sleep(time.Millisecond)
continue
}
b := make([]uint8, 1)
_, err := r.Read(b)
if err != nil {
return Packet{}, err
}
if b[0] == SOF && !started {
started = true
} else if !started {
continue
}
if escaped {
buffer = append(buffer, b[0]^XOR)
escaped = false
} else if b[0] == ESC {
escaped = true
} else {
buffer = append(buffer, b[0])
}
if b[0] == EOF && !escaped {
if len(buffer) == 7 { // Packet size
break
} else {
buffer = []uint8{}
}
}
}
if buffer[0] != SOF || buffer[len(buffer)-1] != EOF {
return Packet{}, ErrInvalidFramingByte
}
command := Command(buffer[1])
data := Data{buffer[2], buffer[3], buffer[4]}
checksum := buffer[5]
pkt := Packet{command, data}
expectedChecksum := pkt.Checksum()
if checksum != expectedChecksum {
return Packet{}, ErrChecksumMismatch
}
return pkt, nil
}

View File

@@ -0,0 +1,206 @@
package proto_test
import (
"bytes"
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/xvzf/computeblade-agent/pkg/smartfanunit/proto"
)
func TestWritePacket(t *testing.T) {
t.Parallel()
testcases := []struct {
name string
packet proto.Packet
expected []uint8
}{
{
name: "Simple packet",
packet: proto.Packet{
Command: proto.Command(0x01),
Data: proto.Data{0x11, 0x12, 0x13},
},
expected: []uint8{proto.SOF, 0x01, 0x11, 0x12, 0x13, 0x11, proto.EOF},
},
{
name: "ESC in payload and checksum == ESC",
packet: proto.Packet{
Command: proto.Command(0x01),
Data: proto.Data{proto.ESC, 0x12, 0x13},
// Checksup: 0x7d -> proto.ESC as well
},
expected: []uint8{
// Start of frame
proto.SOF,
0x01,
// Escaped data
proto.ESC,
proto.XOR ^ proto.ESC,
// continuing non-escaped data
0x12, 0x13,
// escape checksum
proto.ESC,
proto.XOR ^ proto.ESC,
// end of frame
proto.EOF,
},
},
{
name: "EOF, SOF and ESC in payload",
packet: proto.Packet{
// 0x01, 0x7e, 0x7f, 0x7d
Command: proto.Command(0xff),
Data: proto.Data{proto.SOF, proto.EOF, proto.ESC},
// Checksup: 0x7d -> proto.ESC as well
},
expected: []uint8{
// Start of frame
proto.SOF,
0xff,
// Escaped SOF
proto.ESC,
proto.XOR ^ proto.SOF,
// Escaped EOF
proto.ESC,
proto.XOR ^ proto.EOF,
// Escaped ESC
proto.ESC,
proto.XOR ^ proto.ESC,
// Checksum
0x83,
// end of frame
proto.EOF,
},
},
}
for _, tcl := range testcases {
tc := tcl
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var buffer bytes.Buffer
err := proto.WritePacket(context.TODO(), &buffer, tc.packet)
assert.NoError(t, err)
assert.Equal(t, tc.expected, buffer.Bytes())
})
}
}
func FuzzPacketReadWrite(f *testing.F) {
f.Add(uint8(0x01), uint8(0x02), uint8(0x03), uint8(0x04))
// Fuzz function
f.Fuzz(func(t *testing.T, cmd, d0, d1, d2 uint8) {
pkt := proto.Packet{
Command: proto.Command(cmd),
Data: proto.Data([]uint8{d0, d1, d2}),
}
var buffer bytes.Buffer
err := proto.WritePacket(context.TODO(), &buffer, pkt)
assert.NoError(t, err)
readPkt, err := proto.ReadPacket(context.TODO(), &buffer)
assert.NoError(t, err)
assert.Equal(t, pkt, readPkt)
})
}
func TestPacketReadWrite(t *testing.T) {
testcases := []struct {
name string
packet proto.Packet
}{
{
name: "Simple packet",
packet: proto.Packet{
Command: proto.Command(0x01),
Data: proto.Data{0x11, 0x12, 0x13},
},
},
{
name: "EOF, SOF and ESC in payload",
packet: proto.Packet{
Command: proto.Command(0xff),
Data: proto.Data{proto.SOF, proto.EOF, proto.ESC},
},
},
}
for _, tcl := range testcases {
tc := tcl
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var buffer bytes.Buffer
err := proto.WritePacket(context.TODO(), &buffer, tc.packet)
assert.NoError(t, err)
packet, err := proto.ReadPacket(context.TODO(), &buffer)
assert.NoError(t, err)
assert.Equal(t, tc.packet, packet)
})
}
}
func TestReadPacketChecksumError(t *testing.T) {
// Create a simple packet with an invalid Checksum
var buffer bytes.Buffer
invalidPacket := []uint8{
proto.SOF,
0x01,
0x11,
0x22,
0x33,
0x00,
proto.EOF,
} // 0x00 as checksum is invalid here
// Write invalid packet to buffer
for _, b := range invalidPacket {
_, err := buffer.Write([]uint8{b})
if err != nil {
t.Fatalf("Failed to write to buffer: %v", err)
}
}
// Attempt to read the packet with a Checksum error
_, err := proto.ReadPacket(context.TODO(), &buffer)
assert.ErrorIs(t, err, proto.ErrChecksumMismatch)
}
func TestReadPacketDirtyReader(t *testing.T) {
// Create a simple packet with an invalid Checksum
var buffer bytes.Buffer
invalidPacket := []uint8{
// Incomplete previous packet
0x01,
0x12,
0x13,
0x11,
proto.EOF,
// Actual packet
proto.SOF,
0x01,
0x11,
0x12,
0x13,
0x11,
proto.EOF,
}
// Write invalid packet to buffer
for _, b := range invalidPacket {
_, err := buffer.Write([]uint8{b})
if err != nil {
t.Fatalf("Failed to write to buffer: %v", err)
}
}
// Attempt to read the packet with a Checksum error
pkt, err := proto.ReadPacket(context.TODO(), &buffer)
assert.NoError(t, err)
assert.Equal(t, proto.Packet{Command: proto.Command(0x01), Data: proto.Data{0x11, 0x12, 0x13}}, pkt)
}

View File

@@ -0,0 +1,22 @@
package smartfanunit
import (
"github.com/xvzf/computeblade-agent/pkg/smartfanunit/proto"
)
const (
Baudrate = 115200
)
func MatchCmd(cmd proto.Command) func(any) bool {
return func(pktAny any) bool {
pkt, ok := pktAny.(proto.Packet)
if !ok {
return false
}
if pkt.Command == cmd {
return true
}
return false
}
}