Files

639 lines
18 KiB
Go

package roverd
import (
"bufio"
"context"
"errors"
"fmt"
"math"
"os"
"os/exec"
"strconv"
"strings"
"syscall"
"time"
)
const (
hostStatsInterval = 1 * time.Second
rootFilesystem = "/"
)
// HostStats is the wire format sent from roverd to the server. It avoids
// Roomba-specific sensor naming so the browser can treat these values as Pi
// host health, not robot telemetry.
type HostStats struct {
UptimeSec *float64 `json:"uptimeSec,omitempty"`
CPUTempC *float64 `json:"cpuTempC,omitempty"`
CPUUsedPct *float64 `json:"cpuUsedPct,omitempty"`
CoreVoltageV *float64 `json:"coreVoltageV,omitempty"`
LoadAvg1m *float64 `json:"loadAvg1m,omitempty"`
MemoryTotalKb *uint64 `json:"memoryTotalKb,omitempty"`
MemoryAvailableKb *uint64 `json:"memoryAvailableKb,omitempty"`
MemoryUsedPct *float64 `json:"memoryUsedPct,omitempty"`
DiskTotalBytes *uint64 `json:"diskTotalBytes,omitempty"`
DiskFreeBytes *uint64 `json:"diskFreeBytes,omitempty"`
DiskUsedPct *float64 `json:"diskUsedPct,omitempty"`
WiFi *WiFiStats `json:"wifi,omitempty"`
ThrottledRaw string `json:"throttledRaw,omitempty"`
UndervoltageNow *bool `json:"undervoltageNow,omitempty"`
UndervoltageSeen *bool `json:"undervoltageSeen,omitempty"`
FrequencyCapNow *bool `json:"frequencyCappedNow,omitempty"`
FrequencyCapSeen *bool `json:"frequencyCappedSeen,omitempty"`
ThrottledNow *bool `json:"throttledNow,omitempty"`
ThrottledSeen *bool `json:"throttledSeen,omitempty"`
SoftTempLimitNow *bool `json:"softTempLimitNow,omitempty"`
SoftTempLimitSeen *bool `json:"softTempLimitSeen,omitempty"`
Errors map[string]string `json:"errors,omitempty"`
}
// WiFiStats contains only network details that are useful in the UI. The
// wireless interface name is intentionally not part of this struct because the
// rover fleet has one active Wi-Fi interface and the user asked not to display
// it.
type WiFiStats struct {
SSIDSample string `json:"ssidSample,omitempty"`
FrequencyMhz *int `json:"frequencyMhz,omitempty"`
SignalDbm *float64 `json:"signalDbm,omitempty"`
Quality *float64 `json:"quality,omitempty"`
QualityMax *float64 `json:"qualityMax,omitempty"`
NoiseDbm *float64 `json:"noiseDbm,omitempty"`
RXBitrateMbit *float64 `json:"rxBitrateMbit,omitempty"`
TXBitrateMbit *float64 `json:"txBitrateMbit,omitempty"`
RXBytes *uint64 `json:"rxBytes,omitempty"`
TXBytes *uint64 `json:"txBytes,omitempty"`
RXPackets *uint64 `json:"rxPackets,omitempty"`
TXPackets *uint64 `json:"txPackets,omitempty"`
DownloadMbps *float64 `json:"downloadMbps,omitempty"`
UploadMbps *float64 `json:"uploadMbps,omitempty"`
InactiveMs *int `json:"inactiveMs,omitempty"`
// networkSampledAt records the instant associated with the kernel byte
// counters. Keeping it out of JSON lets the websocket loop calculate rates
// with monotonic Go timestamps without expanding the browser contract with
// an implementation-only value.
networkSampledAt time.Time
}
// networkRateSample is scoped to one rover websocket connection. A new
// connection intentionally starts a new baseline so counters from an old boot
// or network interface lifetime can never create an artificial traffic spike.
type networkRateSample struct {
rxBytes uint64
txBytes uint64
sampledAt time.Time
}
// CollectHostStats gathers every source independently so one missing kernel
// file, firmware utility, or Wi-Fi utility does not suppress the remaining Pi
// health data.
func CollectHostStats(ctx context.Context) HostStats {
stats := HostStats{Errors: map[string]string{}}
if uptime, err := readUptimeSec(); err != nil {
stats.addError("uptime", err)
} else {
stats.UptimeSec = &uptime
}
if temp, err := readCPUTempC(); err != nil {
stats.addError("cpuTemp", err)
} else {
stats.CPUTempC = &temp
}
if cpuUsedPct, err := readCPUUsedPct(); err != nil {
stats.addError("cpuUsed", err)
} else {
stats.CPUUsedPct = &cpuUsedPct
}
if load, err := readLoadAvg1m(); err != nil {
stats.addError("loadAvg", err)
} else {
stats.LoadAvg1m = &load
}
if memTotal, memAvailable, err := readMemoryKb(); err != nil {
stats.addError("memory", err)
} else {
stats.MemoryTotalKb = &memTotal
stats.MemoryAvailableKb = &memAvailable
if memTotal > 0 {
usedPct := roundOneDecimal(float64(memTotal-memAvailable) / float64(memTotal) * 100)
stats.MemoryUsedPct = &usedPct
}
}
if diskTotal, diskFree, err := readDiskBytes(rootFilesystem); err != nil {
stats.addError("disk", err)
} else {
stats.DiskTotalBytes = &diskTotal
stats.DiskFreeBytes = &diskFree
if diskTotal > 0 {
usedPct := roundOneDecimal(float64(diskTotal-diskFree) / float64(diskTotal) * 100)
stats.DiskUsedPct = &usedPct
}
}
if coreV, err := readCoreVoltage(ctx); err != nil {
stats.addError("coreVoltage", err)
} else {
stats.CoreVoltageV = &coreV
}
if raw, flags, err := readThrottleFlags(ctx); err != nil {
stats.addError("throttled", err)
} else {
stats.ThrottledRaw = raw
stats.applyThrottleFlags(flags)
}
if wifi, err := collectWiFiStats(ctx); err != nil {
stats.addError("wifi", err)
if wifi != nil {
stats.WiFi = wifi
}
} else {
stats.WiFi = wifi
}
if len(stats.Errors) == 0 {
stats.Errors = nil
}
return stats
}
func (s *HostStats) addError(source string, err error) {
if err == nil {
return
}
if s.Errors == nil {
s.Errors = map[string]string{}
}
s.Errors[source] = err.Error()
}
func (s *HostStats) applyThrottleFlags(flags uint64) {
undervoltageNow := flags&(1<<0) != 0
frequencyCapNow := flags&(1<<1) != 0
throttledNow := flags&(1<<2) != 0
softTempLimitNow := flags&(1<<3) != 0
undervoltageSeen := flags&(1<<16) != 0
frequencyCapSeen := flags&(1<<17) != 0
throttledSeen := flags&(1<<18) != 0
softTempLimitSeen := flags&(1<<19) != 0
s.UndervoltageNow = &undervoltageNow
s.FrequencyCapNow = &frequencyCapNow
s.ThrottledNow = &throttledNow
s.SoftTempLimitNow = &softTempLimitNow
s.UndervoltageSeen = &undervoltageSeen
s.FrequencyCapSeen = &frequencyCapSeen
s.ThrottledSeen = &throttledSeen
s.SoftTempLimitSeen = &softTempLimitSeen
}
func readUptimeSec() (float64, error) {
raw, err := os.ReadFile("/proc/uptime")
if err != nil {
return 0, err
}
fields := strings.Fields(string(raw))
if len(fields) == 0 {
return 0, fmt.Errorf("missing uptime value")
}
return strconv.ParseFloat(fields[0], 64)
}
func readCPUTempC() (float64, error) {
raw, err := os.ReadFile("/sys/class/thermal/thermal_zone0/temp")
if err != nil {
return 0, err
}
milliC, err := strconv.ParseFloat(strings.TrimSpace(string(raw)), 64)
if err != nil {
return 0, err
}
return roundOneDecimal(milliC / 1000), nil
}
func readCPUUsedPct() (float64, error) {
first, err := readCPUTimeSample()
if err != nil {
return 0, err
}
// CPU utilization is a rate, not a directly stored value. Sampling twice
// over a short window gives the UI a true percentage without carrying global
// collector state between websocket messages.
time.Sleep(150 * time.Millisecond)
second, err := readCPUTimeSample()
if err != nil {
return 0, err
}
totalDelta := second.total - first.total
idleDelta := second.idle - first.idle
if totalDelta == 0 {
return 0, fmt.Errorf("zero CPU sample delta")
}
usedPct := (1 - (float64(idleDelta) / float64(totalDelta))) * 100
return roundOneDecimal(usedPct), nil
}
type cpuTimeSample struct {
total uint64
idle uint64
}
func readCPUTimeSample() (cpuTimeSample, error) {
raw, err := os.ReadFile("/proc/stat")
if err != nil {
return cpuTimeSample{}, err
}
lines := strings.Split(string(raw), "\n")
if len(lines) == 0 {
return cpuTimeSample{}, fmt.Errorf("missing /proc/stat cpu line")
}
fields := strings.Fields(lines[0])
if len(fields) < 5 || fields[0] != "cpu" {
return cpuTimeSample{}, fmt.Errorf("invalid /proc/stat cpu line")
}
var values []uint64
for _, field := range fields[1:] {
value, err := strconv.ParseUint(field, 10, 64)
if err != nil {
return cpuTimeSample{}, err
}
values = append(values, value)
}
var total uint64
for _, value := range values {
total += value
}
// Linux reports idle time as idle+iowait. Treating iowait as idle matches
// common CPU usage tools and avoids making disk waits look like CPU work.
idle := values[3]
if len(values) > 4 {
idle += values[4]
}
return cpuTimeSample{total: total, idle: idle}, nil
}
func readLoadAvg1m() (float64, error) {
raw, err := os.ReadFile("/proc/loadavg")
if err != nil {
return 0, err
}
fields := strings.Fields(string(raw))
if len(fields) == 0 {
return 0, fmt.Errorf("missing load average value")
}
load, err := strconv.ParseFloat(fields[0], 64)
if err != nil {
return 0, err
}
return roundTwoDecimals(load), nil
}
func readMemoryKb() (uint64, uint64, error) {
file, err := os.Open("/proc/meminfo")
if err != nil {
return 0, 0, err
}
defer file.Close()
var total uint64
var available uint64
scanner := bufio.NewScanner(file)
for scanner.Scan() {
key, value, ok := parseMeminfoLine(scanner.Text())
if !ok {
continue
}
switch key {
case "MemTotal":
total = value
case "MemAvailable":
available = value
}
}
if err := scanner.Err(); err != nil {
return 0, 0, err
}
if total == 0 || available == 0 {
return 0, 0, fmt.Errorf("missing MemTotal or MemAvailable")
}
return total, available, nil
}
func parseMeminfoLine(line string) (string, uint64, bool) {
parts := strings.Fields(line)
if len(parts) < 2 {
return "", 0, false
}
value, err := strconv.ParseUint(parts[1], 10, 64)
if err != nil {
return "", 0, false
}
return strings.TrimSuffix(parts[0], ":"), value, true
}
func readDiskBytes(path string) (uint64, uint64, error) {
var fs syscall.Statfs_t
if err := syscall.Statfs(path, &fs); err != nil {
return 0, 0, err
}
total := fs.Blocks * uint64(fs.Bsize)
free := fs.Bavail * uint64(fs.Bsize)
return total, free, nil
}
func readCoreVoltage(ctx context.Context) (float64, error) {
out, err := runCommand(ctx, "vcgencmd", "measure_volts", "core")
if err != nil {
return 0, err
}
raw := strings.TrimSpace(out)
raw = strings.TrimPrefix(raw, "volt=")
raw = strings.TrimSuffix(raw, "V")
value, err := strconv.ParseFloat(raw, 64)
if err != nil {
return 0, err
}
return roundThreeDecimals(value), nil
}
func readThrottleFlags(ctx context.Context) (string, uint64, error) {
out, err := runCommand(ctx, "vcgencmd", "get_throttled")
if err != nil {
return "", 0, err
}
raw := strings.TrimSpace(out)
valueText := strings.TrimPrefix(raw, "throttled=")
flags, err := strconv.ParseUint(valueText, 0, 64)
if err != nil {
return raw, 0, err
}
return valueText, flags, nil
}
func collectWiFiStats(ctx context.Context) (*WiFiStats, error) {
iface, stats, err := readWirelessStats()
if err != nil {
return nil, err
}
// The interface is used only for local collection. It is not copied into
// WiFiStats because the UI does not need to expose Linux device names.
iwErr := enrichWiFiWithIW(ctx, iface, stats)
// Read the kernel counters after iw because iw also provides cumulative
// station counters. The kernel interface values deliberately win: they are
// the host-traffic source used for both the cumulative display and Mbps math.
// Link capacity still comes independently from iw's bitrate fields.
counterErr := enrichWiFiWithNetworkCounters(iface, stats)
return stats, errors.Join(counterErr, iwErr)
}
func enrichWiFiWithNetworkCounters(iface string, stats *WiFiStats) error {
basePath := "/sys/class/net/" + iface + "/statistics/"
rxBytes, err := readUintFile(basePath + "rx_bytes")
if err != nil {
return fmt.Errorf("read %s receive bytes: %w", iface, err)
}
txBytes, err := readUintFile(basePath + "tx_bytes")
if err != nil {
return fmt.Errorf("read %s transmit bytes: %w", iface, err)
}
stats.RXBytes = &rxBytes
stats.TXBytes = &txBytes
// Capture the timestamp immediately beside the counter reads so unrelated
// host-stat collection latency cannot distort the elapsed-time divisor.
stats.networkSampledAt = time.Now()
return nil
}
func readUintFile(path string) (uint64, error) {
raw, err := os.ReadFile(path)
if err != nil {
return 0, err
}
return strconv.ParseUint(strings.TrimSpace(string(raw)), 10, 64)
}
func applyNetworkThroughput(stats *WiFiStats, previous *networkRateSample) *networkRateSample {
if stats == nil || stats.RXBytes == nil || stats.TXBytes == nil || stats.networkSampledAt.IsZero() {
// Do not discard the last valid baseline during a temporary read failure.
// The next successful calculation then covers the full elapsed interval and
// remains an accurate average for all traffic transferred during the gap.
return previous
}
current := &networkRateSample{
rxBytes: *stats.RXBytes,
txBytes: *stats.TXBytes,
sampledAt: stats.networkSampledAt,
}
if previous == nil {
return current
}
elapsed := current.sampledAt.Sub(previous.sampledAt).Seconds()
// Linux counters can return to zero after an interface reset. Re-baselining
// on any decrease prevents unsigned underflow from becoming a huge false
// throughput spike in the host-stat card.
if elapsed <= 0 || current.rxBytes < previous.rxBytes || current.txBytes < previous.txBytes {
return current
}
downloadMbps := bytesToMbps(current.rxBytes-previous.rxBytes, elapsed)
uploadMbps := bytesToMbps(current.txBytes-previous.txBytes, elapsed)
stats.DownloadMbps = &downloadMbps
stats.UploadMbps = &uploadMbps
return current
}
func bytesToMbps(byteDelta uint64, elapsedSeconds float64) float64 {
// Mbps uses decimal megabits, matching network equipment and link-rate
// conventions: eight bits per byte and 1,000,000 bits per megabit.
return roundOneDecimal((float64(byteDelta) * 8) / elapsedSeconds / 1_000_000)
}
func readWirelessStats() (string, *WiFiStats, error) {
file, err := os.Open("/proc/net/wireless")
if err != nil {
return "", nil, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
lineNumber := 0
for scanner.Scan() {
lineNumber++
if lineNumber <= 2 {
continue
}
iface, stats, ok := parseWirelessLine(scanner.Text())
if ok {
return iface, stats, nil
}
}
if err := scanner.Err(); err != nil {
return "", nil, err
}
return "", nil, fmt.Errorf("no active wireless stats")
}
func parseWirelessLine(line string) (string, *WiFiStats, bool) {
parts := strings.Fields(strings.TrimSpace(line))
if len(parts) < 5 {
return "", nil, false
}
iface := strings.TrimSuffix(parts[0], ":")
if iface == "" {
return "", nil, false
}
stats := &WiFiStats{}
if quality, err := parseWirelessFloat(parts[2]); err == nil {
stats.Quality = &quality
qualityMax := 70.0
stats.QualityMax = &qualityMax
}
if signal, err := parseWirelessFloat(parts[3]); err == nil && signal > -200 {
stats.SignalDbm = &signal
}
if noise, err := parseWirelessFloat(parts[4]); err == nil && noise > -200 {
stats.NoiseDbm = &noise
}
return iface, stats, true
}
func parseWirelessFloat(raw string) (float64, error) {
return strconv.ParseFloat(strings.TrimSuffix(raw, "."), 64)
}
func enrichWiFiWithIW(ctx context.Context, iface string, stats *WiFiStats) error {
out, err := runCommand(ctx, "iw", "dev", iface, "link")
if err != nil {
return err
}
for _, line := range strings.Split(out, "\n") {
parseIWLine(strings.TrimSpace(line), stats)
}
return nil
}
func parseIWLine(line string, stats *WiFiStats) {
switch {
case strings.HasPrefix(line, "SSID:"):
ssid := strings.TrimSpace(strings.TrimPrefix(line, "SSID:"))
stats.SSIDSample = sampleSSID(ssid)
case strings.HasPrefix(line, "freq:"):
if value, ok := parseFirstInt(strings.TrimSpace(strings.TrimPrefix(line, "freq:"))); ok {
stats.FrequencyMhz = &value
}
case strings.HasPrefix(line, "signal:"):
if value, ok := parseFirstFloat(strings.TrimSpace(strings.TrimPrefix(line, "signal:"))); ok {
stats.SignalDbm = &value
}
case strings.HasPrefix(line, "rx bitrate:"):
if value, ok := parseFirstFloat(strings.TrimSpace(strings.TrimPrefix(line, "rx bitrate:"))); ok {
stats.RXBitrateMbit = &value
}
case strings.HasPrefix(line, "tx bitrate:"):
if value, ok := parseFirstFloat(strings.TrimSpace(strings.TrimPrefix(line, "tx bitrate:"))); ok {
stats.TXBitrateMbit = &value
}
case strings.HasPrefix(line, "inactive time:"):
if value, ok := parseFirstInt(strings.TrimSpace(strings.TrimPrefix(line, "inactive time:"))); ok {
stats.InactiveMs = &value
}
case strings.HasPrefix(line, "RX:"):
parseIWPackedCounter(strings.TrimSpace(strings.TrimPrefix(line, "RX:")), &stats.RXBytes, &stats.RXPackets)
case strings.HasPrefix(line, "TX:"):
parseIWPackedCounter(strings.TrimSpace(strings.TrimPrefix(line, "TX:")), &stats.TXBytes, &stats.TXPackets)
}
}
func parseIWPackedCounter(raw string, bytesTarget **uint64, packetsTarget **uint64) {
fields := strings.Fields(raw)
if len(fields) == 0 {
return
}
if bytes, err := strconv.ParseUint(fields[0], 10, 64); err == nil {
*bytesTarget = &bytes
}
for idx, field := range fields {
if strings.HasPrefix(field, "(") && idx+1 < len(fields) {
packetText := strings.TrimPrefix(field, "(")
if packets, err := strconv.ParseUint(packetText, 10, 64); err == nil {
*packetsTarget = &packets
}
return
}
}
}
func sampleSSID(ssid string) string {
runes := []rune(ssid)
var builder strings.Builder
for idx, r := range runes {
if idx%2 == 0 {
builder.WriteRune(r)
}
}
return builder.String()
}
func parseFirstFloat(raw string) (float64, bool) {
fields := strings.Fields(raw)
if len(fields) == 0 {
return 0, false
}
value, err := strconv.ParseFloat(fields[0], 64)
return value, err == nil
}
func parseFirstInt(raw string) (int, bool) {
fields := strings.Fields(raw)
if len(fields) == 0 {
return 0, false
}
value, err := strconv.Atoi(fields[0])
return value, err == nil
}
func runCommand(ctx context.Context, name string, args ...string) (string, error) {
cmdCtx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond)
defer cancel()
cmd := exec.CommandContext(cmdCtx, name, args...)
out, err := cmd.Output()
if cmdCtx.Err() != nil {
return "", cmdCtx.Err()
}
if err != nil {
return "", err
}
return string(out), nil
}
func roundOneDecimal(value float64) float64 {
return math.Round(value*10) / 10
}
func roundTwoDecimals(value float64) float64 {
return math.Round(value*100) / 100
}
func roundThreeDecimals(value float64) float64 {
return math.Round(value*1000) / 1000
}