diff --git a/agent/sensors_default.go b/agent/sensors_default.go index 5c2c1ce6..2bd1c59c 100644 --- a/agent/sensors_default.go +++ b/agent/sensors_default.go @@ -1,4 +1,4 @@ -//go:build !windows +//go:build !windows && !freebsd package agent diff --git a/agent/sensors_freebsd.go b/agent/sensors_freebsd.go new file mode 100644 index 00000000..a357d8d7 --- /dev/null +++ b/agent/sensors_freebsd.go @@ -0,0 +1,14 @@ +//go:build freebsd + +package agent + +import ( + "context" + + "github.com/shirou/gopsutil/v4/sensors" + "golang.org/x/sys/unix" +) + +var getSensorTemps = func(ctx context.Context) ([]sensors.TemperatureStat, error) { + return getFreeBSDSensorTemps(ctx, unix.SysctlUint32) +} diff --git a/agent/sensors_freebsd_common.go b/agent/sensors_freebsd_common.go new file mode 100644 index 00000000..b6269298 --- /dev/null +++ b/agent/sensors_freebsd_common.go @@ -0,0 +1,81 @@ +//go:build freebsd || testing + +package agent + +import ( + "context" + "fmt" + + "github.com/shirou/gopsutil/v4/sensors" +) + +const ( + freebsdZeroCelsiusDeciKelvin = 2731 + freebsdAcpiThermalZoneCount = 16 +) + +type freebsdSysctlUintReader func(name string) (uint32, error) + +func getFreeBSDSensorTemps(ctx context.Context, readSysctl freebsdSysctlUintReader) ([]sensors.TemperatureStat, error) { + cpuCount, err := readSysctl("hw.ncpu") + if err != nil { + return nil, err + } + temps := make([]sensors.TemperatureStat, 0, int(cpuCount)+freebsdAcpiThermalZoneCount) + for cpu := uint32(0); cpu < cpuCount; cpu++ { + select { + case <-ctx.Done(): + return temps, ctx.Err() + default: + } + + sensorName := fmt.Sprintf("dev.cpu.%d.temperature", cpu) + value, err := readSysctl(sensorName) + if err != nil { + continue + } + temp, ok := freebsdDeciKelvinToCelsius(value) + if !ok { + continue + } + temps = append(temps, sensors.TemperatureStat{ + SensorKey: sensorName, + Temperature: temp, + }) + } + + for zone := 0; zone < freebsdAcpiThermalZoneCount; zone++ { + select { + case <-ctx.Done(): + return temps, ctx.Err() + default: + } + + sensorName := fmt.Sprintf("hw.acpi.thermal.tz%d.temperature", zone) + value, err := readSysctl(sensorName) + if err != nil { + continue + } + temp, ok := freebsdDeciKelvinToCelsius(value) + if !ok { + continue + } + temps = append(temps, sensors.TemperatureStat{ + SensorKey: sensorName, + Temperature: temp, + }) + } + + return temps, nil +} + +func freebsdDeciKelvinToCelsius(value uint32) (float64, bool) { + if value <= freebsdZeroCelsiusDeciKelvin { + return 0, false + } + temp := float64(int64(value)-freebsdZeroCelsiusDeciKelvin) / 10 + if temp <= 0 || temp >= 200 { + return 0, false + } + return temp, true +} diff --git a/agent/sensors_freebsd_common_test.go b/agent/sensors_freebsd_common_test.go new file mode 100644 index 00000000..3bea30ff --- /dev/null +++ b/agent/sensors_freebsd_common_test.go @@ -0,0 +1,167 @@ +//go:build testing + +package agent + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var errFakeFreeBSDSysctlNotFound = errors.New("sysctl not found") + +type fakeFreeBSDSysctls struct { + values map[string]uint32 + errs map[string]error +} + +func (f fakeFreeBSDSysctls) read(name string) (uint32, error) { + if err, ok := f.errs[name]; ok { + return 0, err + } + if value, ok := f.values[name]; ok { + return value, nil + } + return 0, errFakeFreeBSDSysctlNotFound +} + +func TestFreeBSDDeciKelvinToCelsius(t *testing.T) { + tests := []struct { + name string + value uint32 + expected float64 + ok bool + }{ + { + name: "45 Celsius", + value: 3181, + expected: 45, + ok: true, + }, + { + name: "fractional Celsius", + value: 3186, + expected: 45.5, + ok: true, + }, + { + name: "zero deci-Kelvin", + value: 0, + ok: false, + }, + { + name: "zero Celsius", + value: freebsdZeroCelsiusDeciKelvin, + ok: false, + }, + { + name: "below zero Celsius", + value: freebsdZeroCelsiusDeciKelvin - 1, + ok: false, + }, + { + name: "invalid signed integer", + value: 1<<32 - 1, + ok: false, + }, + { + name: "unreasonably high Celsius", + value: freebsdZeroCelsiusDeciKelvin + 2000, + ok: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, ok := freebsdDeciKelvinToCelsius(tt.value) + assert.Equal(t, tt.ok, ok) + assert.InDelta(t, tt.expected, result, 0.001) + }) + } +} + +func TestGetFreeBSDSensorTemps(t *testing.T) { + reader := fakeFreeBSDSysctls{ + values: map[string]uint32{ + "hw.ncpu": 4, + "dev.cpu.0.temperature": 3231, + "dev.cpu.1.temperature": 3242, + "dev.cpu.3.temperature": freebsdZeroCelsiusDeciKelvin, + "hw.acpi.thermal.tz0.temperature": 3101, + "hw.acpi.thermal.tz2.temperature": 3116, + "hw.acpi.thermal.tz3.temperature": freebsdZeroCelsiusDeciKelvin, + "unrelated.sensor.value": 9999, + "dev.cpu.99.temperature": 9999, + "dev.amdtemp.0.core0.foo": 9999, + }, + } + + temps, err := getFreeBSDSensorTemps(context.Background(), reader.read) + + require.NoError(t, err) + require.Len(t, temps, 4) + assert.Equal(t, "dev.cpu.0.temperature", temps[0].SensorKey) + assert.InDelta(t, 50.0, temps[0].Temperature, 0.001) + assert.Equal(t, "dev.cpu.1.temperature", temps[1].SensorKey) + assert.InDelta(t, 51.1, temps[1].Temperature, 0.001) + assert.Equal(t, "hw.acpi.thermal.tz0.temperature", temps[2].SensorKey) + assert.InDelta(t, 37.0, temps[2].Temperature, 0.001) + assert.Equal(t, "hw.acpi.thermal.tz2.temperature", temps[3].SensorKey) + assert.InDelta(t, 38.5, temps[3].Temperature, 0.001) +} + +func TestGetFreeBSDSensorTempsCpuCountError(t *testing.T) { + reader := fakeFreeBSDSysctls{ + errs: map[string]error{ + "hw.ncpu": errors.New("permission denied"), + }, + } + + temps, err := getFreeBSDSensorTemps(context.Background(), reader.read) + + assert.Nil(t, temps) + assert.EqualError(t, err, "permission denied") +} + +func TestGetFreeBSDSensorTempsNoTemperatureSysctls(t *testing.T) { + reader := fakeFreeBSDSysctls{ + values: map[string]uint32{"hw.ncpu": 2}, + } + + temps, err := getFreeBSDSensorTemps(context.Background(), reader.read) + + require.NoError(t, err) + assert.Empty(t, temps) +} + +func TestGetFreeBSDSensorTempsAcpiOnly(t *testing.T) { + reader := fakeFreeBSDSysctls{ + values: map[string]uint32{ + "hw.ncpu": 0, + "hw.acpi.thermal.tz0.temperature": 3081, + }, + } + + temps, err := getFreeBSDSensorTemps(context.Background(), reader.read) + + require.NoError(t, err) + require.Len(t, temps, 1) + assert.Equal(t, "hw.acpi.thermal.tz0.temperature", temps[0].SensorKey) + assert.InDelta(t, 35.0, temps[0].Temperature, 0.001) +} + +func TestGetFreeBSDSensorTempsContextCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + reader := fakeFreeBSDSysctls{ + values: map[string]uint32{"hw.ncpu": 2}, + } + + temps, err := getFreeBSDSensorTemps(ctx, reader.read) + + assert.Empty(t, temps) + assert.ErrorIs(t, err, context.Canceled) +}