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
BIN
View File
Binary file not shown.
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-jio2k7HC.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-d695mNgs.css">
<script type="module" crossorigin src="/assets/index-B5dcDBYS.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Ci1EF4ZU.css">
</head>
<body>
<div id="root"></div>
@@ -32,6 +32,11 @@ function handleMessage(roverId, msg) {
case 'sensor':
roverManager.handleSensorFrame(roverId, msg);
break;
case 'hostStats':
// Host stats are periodic Pi metadata, not user-facing rover events, so
// they are routed directly to roverManager instead of becoming alerts.
roverManager.handleHostStats(roverId, msg);
break;
case 'event': {
const nightVisionOn = coerceBool(msg.data?.nightVisionOn);
if (msg.event === 'nightVision.state' && nightVisionOn != null) {
@@ -88,6 +93,10 @@ roverWSS.on('connection', (ws) => {
if (!roverId) return;
if (msg.type === 'sensor') {
roverManager.handleSensorFrame(roverId, msg);
} else if (msg.type === 'hostStats') {
// Keep the Pi health stream separate from both Roomba sensorFrame data
// and generic rover events, which are surfaced as alerts.
roverManager.handleHostStats(roverId, msg);
} else if (msg.type === 'ack') {
handleAck(msg);
} else if (msg.type === 'event') {
@@ -127,6 +127,7 @@ const {
getRosterForSocket,
broadcastRoster,
setNightVisionState,
handleHostStats,
canSeeRover,
canRequestControl,
} = rosterLifecycle;
@@ -244,6 +245,7 @@ module.exports = {
getRosterForSocket,
broadcastRoster,
setNightVisionState,
handleHostStats,
handleSensorFrame,
requestControl,
releaseControl,
@@ -33,6 +33,7 @@ function createRosterLifecycle(deps) {
meta: null,
ws: null,
lastSensor: null,
lastHostStats: null,
docked: null,
lastBumpAt: null,
drivers: new Set(),
@@ -269,6 +270,30 @@ function createRosterLifecycle(deps) {
managerEvents.emit('rover', { roverId, action: 'nightVision', record });
}
function handleHostStats(roverId, msg = {}) {
const record = rovers.get(roverId);
if (!record) return;
const stats = msg && typeof msg.stats === 'object' && msg.stats ? msg.stats : {};
const receivedAt = Date.now();
// Host stats describe the Pi/Linux side of a rover, so they are stored
// separately from lastSensor and emitted on their own browser event. That
// keeps raw Roomba sensor frames from becoming a mixed metadata channel.
record.lastHostStats = {
raw: msg,
stats,
receivedAt,
};
record.lastSeen = receivedAt;
io.to(record.room).volatile.emit('roverHostStats', {
roverId,
stats,
receivedAt,
});
managerEvents.emit('hostStats', { roverId, stats, receivedAt });
}
function canSeeRover(roverId, socket) {
const record = rovers.get(String(roverId));
return isRoverVisibleToSocket(record, socket);
@@ -292,6 +317,7 @@ function createRosterLifecycle(deps) {
syncSpectatorRooms,
broadcastRoster,
setNightVisionState,
handleHostStats,
canSeeRover,
canRequestControl,
};
+2 -2
View File
@@ -2,6 +2,7 @@
// Purpose: Composes the primary rover control interface and page-level layout. Scope: Orchestrates high-level panels, overlays, and feature modules for the default route.
import { useCallback, useEffect, useMemo, useState } from 'react';
import TelemetryPanel from './components/TelemetryPanel/index.jsx';
import PiHostStatsCard from './components/PiHostStatsCard/index.jsx';
import ReplaySourcesPanel from './components/ReplaySourcesPanel/index.jsx';
import AlertFeed from './components/AlertFeed/index.jsx';
import MobileControls, {
@@ -80,6 +81,7 @@ function DesktopLayout({ layout, onOpenHelpOverlay }) {
<div className={`flex h-full ${themeGapClass} overflow-hidden`}>
<div className={`flex min-w-0 flex-[1.22] flex-col ${themeGapClass} overflow-y-auto pr-0`}>
<DriverVideo />
<PiHostStatsCard />
<TelemetryPanel />
{/* <LogPanel /> */}
</div>
@@ -96,7 +98,6 @@ function MobileFeatureTabs({
layout,
onOpenHelpOverlay,
roomPanelId,
showTelemetry = true,
}) {
const [activeTab, setActiveTab] = useState('chat');
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
@@ -229,7 +230,6 @@ function MobileLandscapeLayout({ onOpenHelpOverlay, swapMobileControlColumns = f
layout="mobile-landscape"
onOpenHelpOverlay={onOpenHelpOverlay}
roomPanelId="mobile-landscape-room"
showTelemetry={false}
/>
</div>
</div>
@@ -0,0 +1,388 @@
// Pi Host Stats Card
// Purpose: Renders Raspberry Pi host health for the assigned rover. Scope: Uses the separate roverHostStats stream, not Roomba sensorFrame data.
import CardFrame from '../CardFrame/index.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
const EMPTY_STATS = Object.freeze({});
const EMPTY_WIFI = Object.freeze({});
const PLACEHOLDER_STATS = Object.freeze({
uptimeSec: 12840,
cpuTempC: 74.2,
cpuUsedPct: 80,
coreVoltageV: 0.5,
loadAvg1m: 0.42,
memoryTotalKb: 948000,
memoryAvailableKb: 417120,
memoryUsedPct: 100,
diskTotalBytes: 31200000000,
diskFreeBytes: 8736000000,
diskUsedPct: 72,
undervoltageNow: true,
undervoltageSeen: true,
frequencyCappedNow: false,
frequencyCappedSeen: true,
throttledNow: false,
throttledSeen: false,
softTempLimitNow: false,
softTempLimitSeen: false,
wifi: Object.freeze({
ssidSample: 'BsmnRvrNt',
frequencyMhz: 2412,
signalDbm: -45,
quality: 10,
qualityMax: 70,
rxBitrateMbit: 72.2,
txBitrateMbit: 58.5,
rxBytes: 12400000,
txBytes: 2300000,
rxPackets: 12640,
txPackets: 6840,
}),
});
function valueOrDash(value) {
return value == null || value === '' ? '--' : value;
}
function finiteNumber(value) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function clampPercent(value) {
const numeric = finiteNumber(value);
if (numeric == null) return null;
return Math.max(0, Math.min(100, numeric));
}
function thresholdTone(value, warnAt, badAt) {
const numeric = finiteNumber(value);
if (numeric == null) return 'neutral';
if (numeric >= badAt) return 'bad';
if (numeric >= warnAt) return 'warn';
return 'good';
}
function toneTextClass(tone) {
if (tone === 'bad') return 'text-red-400';
if (tone === 'warn') return 'text-yellow-400';
if (tone === 'good') return 'text-white';
return 'text-slate-100';
}
function toneFillClass(tone) {
if (tone === 'bad') return 'bg-red-500';
if (tone === 'warn') return 'bg-yellow-500';
if (tone === 'good') return 'bg-white';
return 'bg-slate-300';
}
function formatPercent(value) {
const numeric = finiteNumber(value);
return numeric == null ? '--' : `${Math.round(numeric)}%`;
}
function formatTemp(value) {
const numeric = finiteNumber(value);
return numeric == null ? '--' : `${numeric.toFixed(1)} C`;
}
function formatVoltage(value) {
const numeric = finiteNumber(value);
return numeric == null ? '--' : `${numeric.toFixed(3)} V`;
}
function formatDbm(value) {
const numeric = finiteNumber(value);
return numeric == null ? '--' : `${Math.round(numeric)} dBm`;
}
function formatFrequency(value) {
const numeric = finiteNumber(value);
if (numeric == null) return '--';
return `${Math.round(numeric)} MHz`;
}
function formatBitrate(value) {
const numeric = finiteNumber(value);
return numeric == null ? '--' : `${numeric.toFixed(numeric >= 100 ? 0 : 1)} Mb/s`;
}
function formatBytes(value) {
const numeric = finiteNumber(value);
if (numeric == null) return '--';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let scaled = numeric;
let unitIndex = 0;
while (scaled >= 1024 && unitIndex < units.length - 1) {
scaled /= 1024;
unitIndex += 1;
}
return `${scaled.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
function formatMemoryKb(value) {
const numeric = finiteNumber(value);
if (numeric == null) return '--';
return formatBytes(numeric * 1024);
}
function formatUsedTotal(used, total, formatter) {
const usedValue = finiteNumber(used);
const totalValue = finiteNumber(total);
if (usedValue == null || totalValue == null) return '--';
return `${formatter(usedValue)} / ${formatter(totalValue)}`;
}
function memoryUsedKb(stats) {
const total = finiteNumber(stats.memoryTotalKb);
const available = finiteNumber(stats.memoryAvailableKb);
if (total == null || available == null) return null;
return Math.max(0, total - available);
}
function diskUsedBytes(stats) {
const total = finiteNumber(stats.diskTotalBytes);
const free = finiteNumber(stats.diskFreeBytes);
if (total == null || free == null) return null;
return Math.max(0, total - free);
}
function formatUptime(seconds) {
const numeric = finiteNumber(seconds);
if (numeric == null) return '--';
const total = Math.max(0, Math.floor(numeric));
const days = Math.floor(total / 86400);
const hours = Math.floor((total % 86400) / 3600);
const minutes = Math.floor((total % 3600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
function wifiQualityPercent(wifi) {
const quality = finiteNumber(wifi?.quality);
const max = finiteNumber(wifi?.qualityMax);
if (quality == null || !max) return null;
return (quality / max) * 100;
}
function tempPercent(value) {
const numeric = finiteNumber(value);
if (numeric == null) return null;
// Raspberry Pi thermal behavior becomes interesting long before the hard
// throttle point, so the meter maps a practical 30-85 C operating range
// instead of using 0-100 C where normal values would barely move.
return ((numeric - 30) / (85 - 30)) * 100;
}
function tempTone(value) {
return thresholdTone(value, 70, 80);
}
function signalBars(signalDbm) {
const numeric = finiteNumber(signalDbm);
if (numeric == null) return 0;
if (numeric >= -55) return 4;
if (numeric >= -65) return 3;
if (numeric >= -75) return 2;
return 1;
}
function signalTone(signalDbm) {
const numeric = finiteNumber(signalDbm);
if (numeric == null) return 'neutral';
if (numeric >= -55) return 'good';
if (numeric >= -70) return 'neutral';
if (numeric >= -80) return 'warn';
return 'bad';
}
function powerStatus(stats) {
// Current throttle flags are more important than historical flags because
// they describe active power or thermal limits. Historical flags are still
// surfaced as "seen" so they remain useful without overstating urgency.
if (stats.undervoltageNow) return 'Undervoltage';
if (stats.throttledNow) return 'Throttled';
if (stats.frequencyCappedNow) return 'Freq capped';
if (stats.softTempLimitNow) return 'Soft temp';
if (stats.undervoltageSeen || stats.throttledSeen || stats.frequencyCappedSeen || stats.softTempLimitSeen) return 'Seen';
return 'OK';
}
function powerTone(stats) {
if (stats.undervoltageNow || stats.throttledNow || stats.frequencyCappedNow || stats.softTempLimitNow) return 'bad';
if (stats.undervoltageSeen || stats.throttledSeen || stats.frequencyCappedSeen || stats.softTempLimitSeen) return 'warn';
return 'good';
}
function formatPacketCount(value) {
const numeric = finiteNumber(value);
if (numeric == null) return '--';
if (numeric >= 1000000) return `${(numeric / 1000000).toFixed(1)}m`;
if (numeric >= 1000) return `${(numeric / 1000).toFixed(1)}k`;
return `${Math.round(numeric)}`;
}
function formatTraffic(bytes, packets) {
return `${formatBytes(bytes)} / ${formatPacketCount(packets)}`;
}
function warningMessages(stats) {
const messages = [];
// Current warnings are listed first because they need immediate attention;
// historical flags still matter, but they are lower urgency.
if (stats.undervoltageNow) messages.push('undervoltage now');
if (stats.throttledNow) messages.push('throttled now');
if (stats.frequencyCappedNow) messages.push('frequency capped now');
if (stats.softTempLimitNow) messages.push('soft temp limit now');
if (!stats.undervoltageNow && stats.undervoltageSeen) messages.push('undervoltage seen');
if (!stats.throttledNow && stats.throttledSeen) messages.push('throttled seen');
if (!stats.frequencyCappedNow && stats.frequencyCappedSeen) messages.push('frequency cap seen');
if (!stats.softTempLimitNow && stats.softTempLimitSeen) messages.push('soft temp limit seen');
return messages;
}
export default function PiHostStatsCard() {
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const frame = useTelemetryFrame(roverId);
const stats = frame?.hostStats || PLACEHOLDER_STATS;
const wifi = stats.wifi || EMPTY_WIFI;
const warnings = warningMessages(stats);
const wifiQuality = wifiQualityPercent(wifi);
const bars = signalBars(wifi.signalDbm);
const memoryUsed = memoryUsedKb(stats);
const diskUsed = diskUsedBytes(stats);
const currentPowerTone = powerTone(stats);
const currentTempTone = tempTone(stats.cpuTempC);
const currentSignalTone = signalTone(wifi.signalDbm);
return (
<CardFrame title="Rover Pi Stats" clipOverflow={false} bodyClassName="space-y-0.5 text-sm text-slate-100">
<div className="grid gap-0.5 md:grid-cols-[0.9fr_1fr_1.3fr]">
<section className="min-w-0 space-y-0.5">
<ColumnTitle label="Power info" />
<div className="surface">
<div className="mb-0.5 flex items-baseline justify-between gap-1">
<span className="text-slate-400">CPU Temp</span>
<span className={`leading-none ${toneTextClass(currentTempTone)}`}>{formatTemp(stats.cpuTempC)}</span>
</div>
<Meter percent={tempPercent(stats.cpuTempC)} tone={currentTempTone} />
</div>
<StatRow label="Core Voltage" value={formatVoltage(stats.coreVoltageV)} />
<StatRow label="Uptime" value={formatUptime(stats.uptimeSec)} />
<StatRow label="Power" value={powerStatus(stats)} valueClassName={toneTextClass(currentPowerTone)} />
</section>
<section className="min-w-0 space-y-0.5">
<ColumnTitle label="Resources" />
<BarRow
label="CPU Usage"
value={formatPercent(stats.cpuUsedPct)}
percent={stats.cpuUsedPct}
tone={thresholdTone(stats.cpuUsedPct, 70, 90)}
/>
<BarRow
label="Memory Usage"
value={formatPercent(stats.memoryUsedPct)}
detail={formatUsedTotal(memoryUsed, stats.memoryTotalKb, formatMemoryKb)}
percent={stats.memoryUsedPct}
tone={thresholdTone(stats.memoryUsedPct, 75, 90)}
/>
<BarRow
label="Disk Usage"
value={formatPercent(stats.diskUsedPct)}
detail={formatUsedTotal(diskUsed, stats.diskTotalBytes, formatBytes)}
percent={stats.diskUsedPct}
tone={thresholdTone(stats.diskUsedPct, 80, 92)}
/>
</section>
<section className="min-w-0 space-y-0.5">
<ColumnTitle label="WiFi" />
<div className="surface grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-1">
<div className="min-w-0">
<div className="truncate text-base leading-tight text-slate-100">SSID: {valueOrDash(wifi.ssidSample)}</div>
<div className="text-xs text-slate-400">{formatFrequency(wifi.frequencyMhz)}</div>
</div>
<div className="text-right">
<div className={`font-semibold leading-tight ${toneTextClass(currentSignalTone)}`}>{formatDbm(wifi.signalDbm)}</div>
<SignalBars bars={bars} tone={currentSignalTone} />
</div>
</div>
<BarRow
label="Link Quality"
value={wifi.quality == null ? '--' : `${wifi.quality}/${valueOrDash(wifi.qualityMax)}`}
percent={wifiQuality}
tone={currentSignalTone}
/>
<div className="grid grid-cols-2 gap-0.5">
<StatRow label="RX rate" value={formatBitrate(wifi.rxBitrateMbit)} compact />
<StatRow label="TX rate" value={formatBitrate(wifi.txBitrateMbit)} compact />
<StatRow label="RX" value={formatTraffic(wifi.rxBytes, wifi.rxPackets)} compact />
<StatRow label="TX" value={formatTraffic(wifi.txBytes, wifi.txPackets)} compact />
</div>
</section>
</div>
{warnings.length ? <div className="surface text-xs text-amber-200">{warnings.join(' · ')}</div> : null}
</CardFrame>
);
}
function ColumnTitle({ label }) {
return <div className="text-[0.7rem] font-semibold tracking-wide text-slate-400">{label}</div>;
}
function StatRow({ label, value, compact = false, valueClassName = 'text-slate-100' }) {
return (
<div className={`surface flex min-w-0 items-center justify-between gap-1 ${compact ? 'text-xs' : ''}`}>
<span className="shrink-0 text-slate-400">{label}</span>
<span className={`min-w-0 truncate text-right ${valueClassName}`}>{value}</span>
</div>
);
}
function BarRow({ label, value, detail = null, percent, tone = 'neutral' }) {
return (
<div className="surface">
<div className="mb-0.5 flex items-center justify-between gap-1">
<span className="text-slate-400">{label}</span>
<span className={`min-w-0 truncate text-right ${toneTextClass(tone)}`}>
{detail ? `${value}: ${detail}` : value}
</span>
</div>
<Meter percent={percent} tone={tone} />
</div>
);
}
function Meter({ percent, tone = 'neutral' }) {
const width = clampPercent(percent);
return (
<div className="h-1 bg-neutral-700">
{/* The fill width is the visual data encoding; when data is missing the
track remains visible but empty so the layout can be reviewed in dev. */}
<div className={`h-full ${toneFillClass(tone)}`} style={{ width: width == null ? '0%' : `${width}%` }} />
</div>
);
}
function SignalBars({ bars, tone = 'neutral' }) {
const heights = ['h-1', 'h-2', 'h-3', 'h-4'];
const fillClass = toneFillClass(tone);
return (
<div className="mt-0.5 flex h-4 items-end justify-end gap-0.5" aria-label={`${bars} WiFi signal bars`}>
{heights.map((height, idx) => {
const active = idx < bars;
return <span key={height} className={`w-1 ${height} ${active ? fillClass : 'bg-neutral-700'}`} />;
})}
</div>
);
}
@@ -40,7 +40,7 @@ export default function TelemetryPanel() {
}, [activeDriverId, roverId, selfSocketId, users]);
return (
<CardFrame hideHeader clipOverflow={false} bodyClassName="space-y-0.5 text-base text-slate-100">
<CardFrame title='Roomba sensor values' clipOverflow={false} bodyClassName="space-y-0.5 text-base text-slate-100">
{/* <div className="text-sm text-slate-400">
<span>{connected ? 'online' : 'offline'}</span>
<span> · role {session?.role || 'unknown'}</span>
+23
View File
@@ -61,9 +61,11 @@ export function TelemetryProvider({ children }) {
useEffect(() => {
function handleSensorFrame({ roverId, sensors = {}, frame = {} }) {
if (!roverId) return;
const previous = framesRef.current[roverId] ?? {};
framesRef.current = {
...framesRef.current,
[roverId]: {
...previous,
roverId,
sensors,
raw: frame?.data || null,
@@ -73,9 +75,30 @@ export function TelemetryProvider({ children }) {
notifyRover(roverId);
}
function handleRoverHostStats({ roverId, stats = {}, receivedAt = null }) {
if (!roverId) return;
const previous = framesRef.current[roverId] ?? {};
const now = Date.now();
framesRef.current = {
...framesRef.current,
[roverId]: {
...previous,
roverId,
// Host stats are Pi/Linux metadata. They live beside sensors so UI
// consumers can render Pi health without treating it as raw Roomba
// sensor data.
hostStats: stats,
hostStatsReceivedAt: Number.isFinite(receivedAt) ? receivedAt : now,
},
};
notifyRover(roverId);
}
socket.on('sensorFrame', handleSensorFrame);
socket.on('roverHostStats', handleRoverHostStats);
return () => {
socket.off('sensorFrame', handleSensorFrame);
socket.off('roverHostStats', handleRoverHostStats);
};
}, [socket]);