mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
pi stats from roverd to webui slop
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user