pi stats from roverd to webui slop

This commit is contained in:
legop3
2026-06-03 12:14:47 -04:00
parent 290985bf47
commit dd7e685934
18 changed files with 1177 additions and 131 deletions
+9
View File
@@ -21,6 +21,15 @@ type sensorMessage struct {
Data string `json:"data"`
}
// hostStatsMessage is intentionally separate from sensorMessage because sensor
// frames are raw Roomba Open Interface data, while these values describe the
// Raspberry Pi host that is running roverd.
type hostStatsMessage struct {
Type string `json:"type"`
Timestamp int64 `json:"ts"`
Stats HostStats `json:"stats"`
}
type inboundMessage struct {
Type string `json:"type"`
ID string `json:"id"`
+551
View File
@@ -0,0 +1,551 @@
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
}
+38
View File
@@ -109,6 +109,7 @@ func (c *WSClient) Run(ctx context.Context) error {
}()
go c.forwardSensors(ctx, conn)
go c.forwardEvents(ctx, conn)
go c.forwardHostStats(ctx, conn)
select {
case <-ctx.Done():
@@ -468,6 +469,43 @@ func (c *WSClient) forwardEvents(ctx context.Context, conn *websocket.Conn) {
}
}
func (c *WSClient) forwardHostStats(ctx context.Context, conn *websocket.Conn) {
send := func() bool {
// Host stats are collected on demand so each outbound message describes
// the current Pi state. Collection failures are encoded into the stats
// payload, which keeps this telemetry path from closing the rover socket.
msg := hostStatsMessage{
Type: "hostStats",
Timestamp: time.Now().UnixMilli(),
Stats: CollectHostStats(ctx),
}
if err := writeJSON(ctx, conn, msg); err != nil {
c.log.Printf("host stats send failed: %v", err)
return false
}
return true
}
// Send once immediately so a newly connected rover can populate the UI
// without waiting for the first ticker interval.
if !send() {
return
}
ticker := time.NewTicker(hostStatsInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if !send() {
return
}
}
}
}
func (c *WSClient) emitEvent(event string, data map[string]any) {
if c.events == nil {
return