Skip to content

Commit

Permalink
feat(linux): ✨ add Linux device IO rate sensors
Browse files Browse the repository at this point in the history
  • Loading branch information
joshuar committed Apr 19, 2024
1 parent 596f6e4 commit 1011ea3
Show file tree
Hide file tree
Showing 8 changed files with 338 additions and 3 deletions.
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"dbusx",
"device",
"codecov",
"translations"
"translations",
"logging"
],
"go.testFlags": ["-v"]
}
1 change: 1 addition & 0 deletions internal/agent/device_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ func sensorWorkers() []func(context.Context) chan sensor.Details {
cpu.LoadAvgUpdater,
cpu.UsageUpdater,
disk.UsageUpdater,
disk.IOUpdater,
time.Updater,
power.ScreenLockUpdater,
power.LaptopLidUpdater,
Expand Down
192 changes: 192 additions & 0 deletions internal/linux/disk/diskRates.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
// Copyright (c) 2024 Joshua Rich <[email protected]>
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT

package disk

import (
"context"
"time"

"github.com/iancoleman/strcase"
"github.com/rs/zerolog/log"

"github.com/joshuar/go-hass-agent/internal/device/helpers"
"github.com/joshuar/go-hass-agent/internal/hass/sensor"
"github.com/joshuar/go-hass-agent/internal/hass/sensor/types"
"github.com/joshuar/go-hass-agent/internal/linux"
diskstats "github.com/joshuar/go-hass-agent/pkg/linux/proc"
)

type diskIOSensor struct {
stats map[diskstats.DiskStat]uint64
device string
linux.Sensor
prev uint64
}

type diskIOSensorAttributes struct {
DataSource string `json:"Data Source"`
NativeUnit string `json:"native_unit_of_measurement,omitempty"`
Sectors uint64 `json:"Total Sectors,omitempty"`
Time uint64 `json:"Total Milliseconds,omitempty"`
}

type sensors struct {
totalReads *diskIOSensor
totalWrites *diskIOSensor
readRate *diskIOSensor
writeRate *diskIOSensor
}

func (s *diskIOSensor) Name() string {
return s.device + " " + s.SensorTypeValue.String()
}

func (s *diskIOSensor) ID() string {
return s.device + "_" + strcase.ToSnake(s.SensorTypeValue.String())
}

func (s *diskIOSensor) Attributes() any {
switch s.SensorTypeValue {
case linux.SensorDiskReads:
return &diskIOSensorAttributes{
DataSource: linux.DataSrcProcfs,
Sectors: s.stats[diskstats.TotalSectorsRead],
Time: s.stats[diskstats.TotalTimeReading],
}
case linux.SensorDiskWrites:
return &diskIOSensorAttributes{
DataSource: linux.DataSrcProcfs,
Sectors: s.stats[diskstats.TotalSectorsWritten],
Time: s.stats[diskstats.TotalTimeWriting],
}
case linux.SensorDiskReadRate:
return &diskIOSensorAttributes{
DataSource: linux.DataSrcProcfs,
NativeUnit: "KB/s",
}
case linux.SensorDiskWriteRate:
return &diskIOSensorAttributes{
DataSource: linux.DataSrcProcfs,
NativeUnit: "KB/s",
}
}
return nil
}

func (s *diskIOSensor) Icon() string {
switch s.SensorTypeValue {
case linux.SensorDiskReads:
return "mdi:file-upload"
case linux.SensorDiskWrites:
return "mdi:file-download"
}
return "mdi:file"
}

func (s *diskIOSensor) update(stats map[diskstats.DiskStat]uint64, delta time.Duration) {
s.stats = stats
var curr uint64
switch s.SensorTypeValue {
case linux.SensorDiskReads:
s.Value = s.stats[diskstats.TotalReads]
case linux.SensorDiskWrites:
s.Value = s.stats[diskstats.TotalWrites]
case linux.SensorDiskReadRate:
curr = s.stats[diskstats.TotalSectorsRead]
case linux.SensorDiskWriteRate:
curr = s.stats[diskstats.TotalSectorsWritten]
}
if s.SensorTypeValue == linux.SensorDiskReadRate || s.SensorTypeValue == linux.SensorDiskWriteRate {
if uint64(delta.Seconds()) > 0 {
log.Trace().Msgf("%s IO rate calc: (%d - %d) / uint64(%d) / 2", s.device, curr, s.prev, uint64(delta.Seconds()))
s.Value = (curr - s.prev) / uint64(delta.Seconds()) / 2
}
s.prev = curr
}
}

func newDiskIOSensor(device string, sensorType linux.SensorTypeValue) *diskIOSensor {
s := &diskIOSensor{
device: device,
Sensor: linux.Sensor{
DeviceClassValue: types.DeviceClassDataSize,
StateClassValue: types.StateClassTotalIncreasing,
SensorTypeValue: sensorType,
IsDiagnostic: true,
},
}
return s
}

func newDiskIORateSensor(device string, sensorType linux.SensorTypeValue) *diskIOSensor {
s := &diskIOSensor{
device: device,
Sensor: linux.Sensor{
DeviceClassValue: types.DeviceClassDataRate,
StateClassValue: types.StateClassMeasurement,
UnitsString: "KB/s",
SensorTypeValue: sensorType,
},
}
return s
}

func newDevice(dev string) *sensors {
return &sensors{
totalReads: newDiskIOSensor(dev, linux.SensorDiskReads),
totalWrites: newDiskIOSensor(dev, linux.SensorDiskWrites),
readRate: newDiskIORateSensor(dev, linux.SensorDiskReadRate),
writeRate: newDiskIORateSensor(dev, linux.SensorDiskWriteRate),
}
}

func IOUpdater(ctx context.Context) chan sensor.Details {
sensorCh := make(chan sensor.Details)
newStats, err := diskstats.ReadDiskstats()
if err != nil {
log.Warn().Err(err).Msg("Error reading disk stats from procfs. Will not send disk rate sensors.")
close(sensorCh)
return sensorCh
}
devices := make(map[string]*sensors)
for dev := range newStats {
devices[dev] = newDevice(dev)
}
diskIOstats := func(delta time.Duration) {
newStats, err := diskstats.ReadDiskstats()
if err != nil {
log.Warn().Err(err).Msg("Error reading disk stats from procfs.")
}
for dev, stats := range newStats {
if _, ok := devices[dev]; !ok {
devices[dev] = newDevice(dev)
}
devices[dev].totalReads.update(stats, delta)
devices[dev].totalWrites.update(stats, delta)
devices[dev].readRate.update(stats, delta)
devices[dev].writeRate.update(stats, delta)
go func(d string) {
sensorCh <- devices[d].totalReads
}(dev)
go func(d string) {
sensorCh <- devices[d].totalWrites
}(dev)
go func(d string) {
sensorCh <- devices[d].readRate
}(dev)
go func(d string) {
sensorCh <- devices[d].writeRate
}(dev)
}
}
go helpers.PollSensors(ctx, diskIOstats, 5*time.Second, time.Second*1)
go func() {
defer close(sensorCh)
<-ctx.Done()
log.Debug().Msg("Stopped disk IO sensors.")
}()
return sensorCh
}
4 changes: 4 additions & 0 deletions internal/linux/sensorType.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ const (
SensorPowerState // Power State
SensorAccentColor // Accent Color
SensorColorScheme // Color Scheme Type
SensorDiskReads // Disk Reads
SensorDiskWrites // Disk Writes
SensorDiskReadRate // Disk Read Rate
SensorDiskWriteRate // Disk Write Rate
)

// SensorTypeValue represents the unique type of sensor data being reported. Every
Expand Down
8 changes: 6 additions & 2 deletions internal/linux/sensorTypeStrings.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 40 additions & 0 deletions pkg/linux/proc/diskStatStrings.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

72 changes: 72 additions & 0 deletions pkg/linux/proc/diskstats.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Copyright (c) 2024 Joshua Rich <[email protected]>
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT

package diskstats

import (
"os"
"slices"
"strconv"
"strings"

"github.com/rs/zerolog/log"
)

//go:generate stringer -type=DiskStat -output diskStatStrings.go -linecomment
const (
TotalReads DiskStat = iota + 3 // Total reads completed
TotalReadsMerged // Total reads merged
TotalSectorsRead // Total sectors read
TotalTimeReading // Total milliseconds spent reading
TotalWrites // Total writes completed
TotalWritesMerged // Total writes merged
TotalSectorsWritten // Total sectors written
TotalTimeWriting // Total milliseconds spent writing
ActiveIOs // I/Os currently in progress
ActiveIOTime // Milliseconds elapsed spent doing I/Os
ActiveIOTimeWeighted // Milliseconds elapsed spent doing I/Os (weighted)
TotalDiscardsCompleted // Total discards completed
TotalDiscardsMerged // Total discards merged
TotalSectorsDiscarded // Total sectors discarded
TotalTimeDiscarding // Total milliseconds spent discarding
TotalFlushRequests // Total flush requests completed
TotalTimeFlushing // Total milliseconds spent flushing
)

type DiskStat int

func ReadDiskstats() (map[string]map[DiskStat]uint64, error) {
data, err := os.ReadFile("/proc/diskstats")
if err != nil {
return nil, err
}

stats := make(map[string]map[DiskStat]uint64)
lines := strings.Split(string(data), "\n")
for _, line := range lines[:len(lines)-1] {
fields := strings.Split(line, " ")
fields = slices.DeleteFunc(fields, func(n string) bool {
return n == ""
})
device := fields[2]
stats[device] = make(map[DiskStat]uint64)
for i, f := range fields {
if i < 3 {
continue
}
stat := DiskStat(i)
readVal, err := strconv.ParseUint(f, 10, 64)
if err != nil {
log.Warn().
Err(err).
Str("stat", stat.String()).
Str("device", device).
Msg("Unable to read disk stat.")
}
stats[device][stat] = readVal
}
}
return stats, nil
}
21 changes: 21 additions & 0 deletions pkg/linux/proc/examples/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright (c) 2024 Joshua Rich <[email protected]>
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT

package main

import (
"github.com/davecgh/go-spew/spew"
"github.com/rs/zerolog/log"

diskstats "github.com/joshuar/go-hass-agent/pkg/linux/proc"
)

func main() {
stats, err := diskstats.ReadDiskstats()
if err != nil {
log.Fatal().Err(err).Msg("Could not read.")
}
spew.Dump(stats)
}

0 comments on commit 1011ea3

Please sign in to comment.