Files
hamlab/internal/sensors/sensors.go
T
Mitchell-JohnsonandClaude Fable 5.1 8ee2a2d293 Bug bash 2026-09-19: 17 bugs fixed and verified (BUGS.md)
Widgets: a bad pattern no longer silences every widget; malformed layouts load.
Shell: lost session returns to login at once; tab titles; Escape closes menus and dialogs.
Files: names are one path segment; unsaved edits ask before page/machine/tab changes;
only folders that opened are remembered. Terminals: unique default names; 64-char names.
API: queries capped at 10 000 values; 32 streams per client, 512 in all; only
/embed/widget is frameable. ZFS sensors: one unprivileged zpool list, no sudo journal spam.
Phone: 36 px inputs; dataset rows as wide as their cells. Machines: valid name pattern.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 10:18:23 -07:00

276 lines
8.3 KiB
Go

// Package sensors is the one standard way anything reads what Ham Lab measures.
//
// Every measurable thing is a Sensor: a stable dotted ID ("cpu.core.3.usage",
// "fan.nct6775.fan2", "fs.root.used_pct"), a little metadata, and a current value. A
// consumer discovers them from the catalog, then asks for exactly the ones it wants —
// one by ID, many by glob, or as a shaped query where the client sends the object it
// wants and gets it back with the values filled in. See SENSORS.md.
//
// Sources of sensors are Providers. The registry only joins them; it measures nothing.
package sensors
import (
"encoding/json"
"errors"
"fmt"
"path"
"sort"
"strings"
)
// Sensor is one measurable thing and its reading. Value is a float64, bool or string.
type Sensor struct {
ID string `json:"id"`
Name string `json:"name"` // human label
Kind string `json:"kind"` // usage | load | temperature | fan | rate | bytes | duration | voltage | power | state
Unit string `json:"unit,omitempty"` // % · °C · rpm · B/s · B · s · min · V · W · MHz
Value any `json:"value"`
T int64 `json:"t"` // unix ms the value was read — slow sensors are older than the tick
Min *float64 `json:"min,omitempty"`
Max *float64 `json:"max,omitempty"`
Warn *float64 `json:"warn,omitempty"` // the hardware's own thresholds, where it states them
Crit *float64 `json:"crit,omitempty"`
Labels map[string]string `json:"labels,omitempty"` // the real names behind the ID: chip, iface, mount…
}
// Series is a numeric sensor's recent past.
type Series struct {
ID string `json:"id"`
Unit string `json:"unit,omitempty"`
T []int64 `json:"t"`
V []float64 `json:"v"`
}
// Provider is a source of sensors. Sensors returns every sensor it has, with current values.
type Provider interface {
Sensors() []Sensor
}
// Historian is a Provider that also remembers: the recent past of every numeric sensor
// of its own that match accepts.
type Historian interface {
SensorHistories(match func(id string) bool) []Series
}
// Ticker is a Provider that can say when its values have changed.
type Ticker interface {
Subscribe() (tick <-chan struct{}, cancel func())
}
// Subscribe follows the first provider that ticks. ok is false when none does.
func (r *Registry) Subscribe() (tick <-chan struct{}, cancel func(), ok bool) {
for _, p := range r.providers {
if t, is := p.(Ticker); is {
tick, cancel = t.Subscribe()
return tick, cancel, true
}
}
return nil, nil, false
}
// F is a pointer to a float, for the optional bounds.
func F(v float64) *float64 { return &v }
// Seg makes one ID segment from a real-world name: lower case, [a-z0-9_-] only, so IDs
// survive URLs, query strings and templating languages untouched. "Package id 0" →
// "package_id_0", "/mnt/tank" → "mnt_tank", "/" → "root".
func Seg(name string) string {
if name == "/" {
return "root"
}
var b strings.Builder
gap := false
for _, r := range strings.ToLower(strings.TrimSpace(name)) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
if gap && b.Len() > 0 {
b.WriteByte('_')
}
gap = false
b.WriteRune(r)
default:
gap = true
}
}
if b.Len() == 0 {
return "unnamed"
}
return b.String()
}
// ID joins already-safe segments.
func ID(segs ...string) string { return strings.Join(segs, ".") }
// Registry joins providers into one namespace.
type Registry struct{ providers []Provider }
func NewRegistry(p ...Provider) *Registry { return &Registry{providers: p} }
func (r *Registry) Add(p Provider) { r.providers = append(r.providers, p) }
// All returns every sensor, sorted by ID. Two sensors that would share an ID (two
// identical chips) get "_2", "_3" on the colliding one, in the provider's order.
func (r *Registry) All() []Sensor {
var out []Sensor
seen := map[string]int{}
for _, p := range r.providers {
for _, s := range p.Sensors() {
seen[s.ID]++
if n := seen[s.ID]; n > 1 {
s.ID = fmt.Sprintf("%s_%d", s.ID, n)
}
out = append(out, s)
}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
// History is one sensor's recent past. ok is false when nothing remembers it.
func (r *Registry) History(id string) (Series, bool) {
got := r.Histories(func(x string) bool { return x == id })
if len(got) == 0 {
return Series{}, false
}
return got[0], true
}
// Histories is the recent past of every numeric sensor match accepts, sorted by ID.
func (r *Registry) Histories(match func(id string) bool) []Series {
out := []Series{}
for _, p := range r.providers {
if h, ok := p.(Historian); ok {
out = append(out, h.SensorHistories(match)...)
}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
// Matcher turns comma-separated selectors (IDs and globs) into a match function.
func Matcher(selectors string) (func(id string) bool, error) {
var sels []string
for _, sel := range strings.Split(selectors, ",") {
if sel = strings.TrimSpace(sel); sel == "" {
continue
}
if _, err := path.Match(sel, ""); err != nil {
return nil, fmt.Errorf("bad pattern %q", sel)
}
sels = append(sels, sel)
}
return func(id string) bool {
for _, sel := range sels {
if ok, _ := path.Match(sel, id); ok {
return true
}
}
return false
}, nil
}
// IsGlob reports whether a selector names many sensors rather than one.
func IsGlob(sel string) bool { return strings.ContainsAny(sel, "*?[") }
// Select returns the sensors a selector names: an exact ID, or a glob where * crosses
// dots ("fan.*" is every fan, "cpu.core.*.usage" every core).
func Select(all []Sensor, sel string) ([]Sensor, error) {
if !IsGlob(sel) {
for _, s := range all {
if s.ID == sel {
return []Sensor{s}, nil
}
}
return nil, nil
}
if _, err := path.Match(sel, ""); err != nil {
return nil, fmt.Errorf("bad pattern %q", sel)
}
var out []Sensor
for _, s := range all {
if ok, _ := path.Match(sel, s.ID); ok {
out = append(out, s)
}
}
return out, nil
}
// Shape answers a shaped query: the client sends the object it wants, with selectors
// where the values go, and gets the same object back with them filled in.
//
// {"cpu": "cpu.usage", "fans": "fan.*", "box": {"up": "uptime"}}
// → {"cpu": 12.4, "fans": {"fan.nct6775.fan1": 820}, "box": {"up": 80520}}
//
// An exact ID becomes its value (null if there is no such sensor here — machines differ,
// and a dashboard shouldn't fail because one has no fans). A glob becomes an object keyed
// by ID. Objects and arrays are walked. With full, every value is the whole Sensor.
func Shape(all []Sensor, query json.RawMessage, full bool) (any, error) {
var q any
if err := json.Unmarshal(query, &q); err != nil {
return nil, errors.New("query is not valid JSON")
}
budget := MaxValues
return shape(all, q, full, 0, &budget)
}
// MaxValues is how many sensor values one query may return. A query is a few bytes that
// can ask for everything many times over ({"a":"*","b":"*",…}); without a ceiling 10 KB of
// request buys 27 MB of response. Ten thousand is thirty times a whole machine.
const MaxValues = 10000
func shape(all []Sensor, q any, full bool, depth int, budget *int) (any, error) {
if depth > 16 {
return nil, errors.New("query nested too deep")
}
leaf := func(s Sensor) any {
if full {
return s
}
return s.Value
}
switch v := q.(type) {
case string:
got, err := Select(all, v)
if err != nil {
return nil, err
}
if !IsGlob(v) {
if len(got) == 0 {
return nil, nil
}
*budget--
return leaf(got[0]), nil
}
if *budget -= len(got); *budget < 0 {
return nil, fmt.Errorf("query returns more than %d values; ask for less", MaxValues)
}
m := make(map[string]any, len(got))
for _, s := range got {
m[s.ID] = leaf(s)
}
return m, nil
case map[string]any:
m := make(map[string]any, len(v))
for k, sub := range v {
r, err := shape(all, sub, full, depth+1, budget)
if err != nil {
return nil, err
}
m[k] = r
}
return m, nil
case []any:
a := make([]any, len(v))
for i, sub := range v {
r, err := shape(all, sub, full, depth+1, budget)
if err != nil {
return nil, err
}
a[i] = r
}
return a, nil
default:
return nil, fmt.Errorf("a query holds sensor IDs, objects and arrays; got %v", v)
}
}