Every number the collector samples becomes a sensor with a stable dotted ID (cpu.core.3.usage, fan.nct6775.fan2, fs.root.used_pct). Clients discover them from the catalog and ask for exactly what they want, including as a shaped query: send the object you want, get it back with the values filled in. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
218 lines
6.4 KiB
Go
218 lines
6.4 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. ok is false for an ID it doesn't hold.
|
|
type Historian interface {
|
|
SensorHistory(id string) (s Series, ok bool)
|
|
}
|
|
|
|
// 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 finds the provider that remembers id.
|
|
func (r *Registry) History(id string) (Series, bool) {
|
|
for _, p := range r.providers {
|
|
if h, ok := p.(Historian); ok {
|
|
if s, ok := h.SensorHistory(id); ok {
|
|
return s, true
|
|
}
|
|
}
|
|
}
|
|
return Series{}, false
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
return shape(all, q, full, 0)
|
|
}
|
|
|
|
func shape(all []Sensor, q any, full bool, depth 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
|
|
}
|
|
return leaf(got[0]), nil
|
|
}
|
|
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)
|
|
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)
|
|
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)
|
|
}
|
|
}
|