mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 17:40:46 -04:00
639 lines
18 KiB
Go
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
|
|
}
|