package roverd import ( "bufio" "context" "fmt" "math" "os" "os/exec" "strconv" "strings" "syscall" "time" ) const ( hostStatsInterval = 5 * 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"` InactiveMs *int `json:"inactiveMs,omitempty"` } // 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 to ask iw about the active connection. It is // not copied into WiFiStats because the UI does not need to expose it. if err := enrichWiFiWithIW(ctx, iface, stats); err != nil { return stats, err } return stats, nil } 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 }