Sensors: one standard read interface — catalog, single value, history, shaped query
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>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
01aeb226ea
commit
43bf787696
@@ -0,0 +1,217 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user