diff --git a/.vscode/settings.json b/.vscode/settings.json index 13c898939..16bc1f907 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -17,7 +17,8 @@ "dbusx", "device", "codecov", - "translations" + "translations", + "logging" ], "go.testFlags": ["-v"] } \ No newline at end of file diff --git a/internal/agent/device_linux.go b/internal/agent/device_linux.go index aa1596312..bbb9f042e 100644 --- a/internal/agent/device_linux.go +++ b/internal/agent/device_linux.go @@ -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, diff --git a/internal/linux/disk/diskRates.go b/internal/linux/disk/diskRates.go new file mode 100644 index 000000000..f63a2820f --- /dev/null +++ b/internal/linux/disk/diskRates.go @@ -0,0 +1,192 @@ +// Copyright (c) 2024 Joshua Rich +// +// 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 +} diff --git a/internal/linux/sensorType.go b/internal/linux/sensorType.go index 0e3192a29..dcfba76b7 100644 --- a/internal/linux/sensorType.go +++ b/internal/linux/sensorType.go @@ -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 diff --git a/internal/linux/sensorTypeStrings.go b/internal/linux/sensorTypeStrings.go index 72258e7b0..27d182b3a 100644 --- a/internal/linux/sensorTypeStrings.go +++ b/internal/linux/sensorTypeStrings.go @@ -63,11 +63,15 @@ func _() { _ = x[SensorPowerState-53] _ = x[SensorAccentColor-54] _ = x[SensorColorScheme-55] + _ = x[SensorDiskReads-56] + _ = x[SensorDiskWrites-57] + _ = x[SensorDiskReadRate-58] + _ = x[SensorDiskWriteRate-59] } -const _SensorTypeValue_name = "Active AppRunning AppsBattery TypeBattery LevelBattery TemperatureBattery VoltageBattery EnergyBattery PowerBattery StateBattery PathBattery LevelBattery ModelMemory TotalMemory AvailableMemory UsedMemory UsageSwap Memory TotalSwap Memory UsedSwap Memory FreeSwap UsageConnection StateConnection IDConnection DeviceConnection TypeConnection IPv4Connection IPv6IPv4 AddressIPv6 AddressWi-Fi SSIDWi-Fi FrequencyWi-Fi Link SpeedWi-Fi Signal StrengthWi-Fi BSSIDBytes SentBytes ReceivedBytes Sent ThroughputBytes Received ThroughputPower ProfileLast RebootUptimeCPU load average (1 min)CPU load average (5 min)CPU load average (15 min)CPU UsageScreen LockLaptop LidProblemsKernel VersionDistribution NameDistribution VersionCurrent UsersTemperaturePower StateAccent ColorColor Scheme Type" +const _SensorTypeValue_name = "Active AppRunning AppsBattery TypeBattery LevelBattery TemperatureBattery VoltageBattery EnergyBattery PowerBattery StateBattery PathBattery LevelBattery ModelMemory TotalMemory AvailableMemory UsedMemory UsageSwap Memory TotalSwap Memory UsedSwap Memory FreeSwap UsageConnection StateConnection IDConnection DeviceConnection TypeConnection IPv4Connection IPv6IPv4 AddressIPv6 AddressWi-Fi SSIDWi-Fi FrequencyWi-Fi Link SpeedWi-Fi Signal StrengthWi-Fi BSSIDBytes SentBytes ReceivedBytes Sent ThroughputBytes Received ThroughputPower ProfileLast RebootUptimeCPU load average (1 min)CPU load average (5 min)CPU load average (15 min)CPU UsageScreen LockLaptop LidProblemsKernel VersionDistribution NameDistribution VersionCurrent UsersTemperaturePower StateAccent ColorColor Scheme TypeDisk ReadsDisk WritesDisk Read RateDisk Write Rate" -var _SensorTypeValue_index = [...]uint16{0, 10, 22, 34, 47, 66, 81, 95, 108, 121, 133, 146, 159, 171, 187, 198, 210, 227, 243, 259, 269, 285, 298, 315, 330, 345, 360, 372, 384, 394, 409, 425, 446, 457, 467, 481, 502, 527, 540, 551, 557, 581, 605, 630, 639, 650, 660, 668, 682, 699, 719, 732, 743, 754, 766, 783} +var _SensorTypeValue_index = [...]uint16{0, 10, 22, 34, 47, 66, 81, 95, 108, 121, 133, 146, 159, 171, 187, 198, 210, 227, 243, 259, 269, 285, 298, 315, 330, 345, 360, 372, 384, 394, 409, 425, 446, 457, 467, 481, 502, 527, 540, 551, 557, 581, 605, 630, 639, 650, 660, 668, 682, 699, 719, 732, 743, 754, 766, 783, 793, 804, 818, 833} func (i SensorTypeValue) String() string { i -= 1 diff --git a/pkg/linux/proc/diskStatStrings.go b/pkg/linux/proc/diskStatStrings.go new file mode 100644 index 000000000..69524c910 --- /dev/null +++ b/pkg/linux/proc/diskStatStrings.go @@ -0,0 +1,40 @@ +// Code generated by "stringer -type=DiskStat -output diskStatStrings.go -linecomment"; DO NOT EDIT. + +package diskstats + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[TotalReads-3] + _ = x[TotalReadsMerged-4] + _ = x[TotalSectorsRead-5] + _ = x[TotalTimeReading-6] + _ = x[TotalWrites-7] + _ = x[TotalWritesMerged-8] + _ = x[TotalSectorsWritten-9] + _ = x[TotalTimeWriting-10] + _ = x[ActiveIOs-11] + _ = x[ActiveIOTime-12] + _ = x[ActiveIOTimeWeighted-13] + _ = x[TotalDiscardsCompleted-14] + _ = x[TotalDiscardsMerged-15] + _ = x[TotalSectorsDiscarded-16] + _ = x[TotalTimeDiscarding-17] + _ = x[TotalFlushRequests-18] + _ = x[TotalTimeFlushing-19] +} + +const _DiskStat_name = "Total reads completedTotal reads mergedTotal sectors readTotal milliseconds spent readingTotal writes completedTotal writes mergedTotal sectors writtenTotal milliseconds spent writingI/Os currently in progressMilliseconds elapsed spent doing I/OsMilliseconds elapsed spent doing I/Os (weighted)Total discards completedTotal discards mergedTotal sectors discardedTotal milliseconds spent discardingTotal flush requests completedTotal milliseconds spent flushing" + +var _DiskStat_index = [...]uint16{0, 21, 39, 57, 89, 111, 130, 151, 183, 209, 246, 294, 318, 339, 362, 397, 427, 460} + +func (i DiskStat) String() string { + i -= 3 + if i < 0 || i >= DiskStat(len(_DiskStat_index)-1) { + return "DiskStat(" + strconv.FormatInt(int64(i+3), 10) + ")" + } + return _DiskStat_name[_DiskStat_index[i]:_DiskStat_index[i+1]] +} diff --git a/pkg/linux/proc/diskstats.go b/pkg/linux/proc/diskstats.go new file mode 100644 index 000000000..238ae22eb --- /dev/null +++ b/pkg/linux/proc/diskstats.go @@ -0,0 +1,72 @@ +// Copyright (c) 2024 Joshua Rich +// +// 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 +} diff --git a/pkg/linux/proc/examples/main.go b/pkg/linux/proc/examples/main.go new file mode 100644 index 000000000..234ca1a78 --- /dev/null +++ b/pkg/linux/proc/examples/main.go @@ -0,0 +1,21 @@ +// Copyright (c) 2024 Joshua Rich +// +// 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) +}