// 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) } // 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 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) } }