213 lines
4.9 KiB
Go
213 lines
4.9 KiB
Go
// Package servervar provides a small persistent key/value data source for
|
|
// "server variables": named scalar values that the server-side control-logic
|
|
// engine writes (e.g. the state of a sequence) and that interface panels can
|
|
// read live. Panels may read any variable; writes from panels are gated to
|
|
// control-logic editors in the WebSocket write handler.
|
|
package servervar
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/uopi/uopi/internal/datasource"
|
|
)
|
|
|
|
const fileName = "servervars.json"
|
|
|
|
type variable struct {
|
|
value float64
|
|
ts time.Time
|
|
}
|
|
|
|
// Source is the "srv" data source. Variables are created on first write and
|
|
// persisted so their last value survives a restart.
|
|
type Source struct {
|
|
path string
|
|
|
|
mu sync.RWMutex
|
|
vars map[string]*variable
|
|
subs map[string]map[int]chan<- datasource.Value
|
|
nextID int
|
|
}
|
|
|
|
// New opens (or initialises) the server-variable store under storageDir.
|
|
func New(storageDir string) (*Source, error) {
|
|
s := &Source{
|
|
path: filepath.Join(storageDir, fileName),
|
|
vars: map[string]*variable{},
|
|
subs: map[string]map[int]chan<- datasource.Value{},
|
|
}
|
|
if err := s.load(); err != nil {
|
|
return nil, err
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func (s *Source) load() error {
|
|
data, err := os.ReadFile(s.path)
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var raw map[string]float64
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
return err
|
|
}
|
|
now := time.Now()
|
|
for name, v := range raw {
|
|
s.vars[name] = &variable{value: v, ts: now}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// saveLocked persists the current values atomically. Caller holds s.mu.
|
|
func (s *Source) saveLocked() {
|
|
raw := make(map[string]float64, len(s.vars))
|
|
for name, v := range s.vars {
|
|
raw[name] = v.value
|
|
}
|
|
data, err := json.MarshalIndent(raw, "", " ")
|
|
if err != nil {
|
|
return
|
|
}
|
|
tmp := s.path + ".tmp"
|
|
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
|
return
|
|
}
|
|
_ = os.Rename(tmp, s.path)
|
|
}
|
|
|
|
func meta(name string) datasource.Metadata {
|
|
return datasource.Metadata{
|
|
Name: name,
|
|
Type: datasource.TypeFloat64,
|
|
Description: "Server variable",
|
|
Writable: true,
|
|
}
|
|
}
|
|
|
|
// Name implements datasource.DataSource.
|
|
func (s *Source) Name() string { return "srv" }
|
|
|
|
// Connect is a no-op — the store is opened in New.
|
|
func (s *Source) Connect(_ context.Context) error { return nil }
|
|
|
|
// ListSignals returns metadata for every defined server variable.
|
|
func (s *Source) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
names := make([]string, 0, len(s.vars))
|
|
for name := range s.vars {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
out := make([]datasource.Metadata, 0, len(names))
|
|
for _, name := range names {
|
|
out = append(out, meta(name))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// GetMetadata returns metadata for a single variable. Unknown names still report
|
|
// writable metadata so that control logic / authorised panels may create them.
|
|
func (s *Source) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
|
|
return meta(signal), nil
|
|
}
|
|
|
|
// Subscribe registers ch for updates and immediately delivers the current value.
|
|
func (s *Source) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
|
|
s.mu.Lock()
|
|
if s.subs[signal] == nil {
|
|
s.subs[signal] = map[int]chan<- datasource.Value{}
|
|
}
|
|
id := s.nextID
|
|
s.nextID++
|
|
s.subs[signal][id] = ch
|
|
cur, ok := s.vars[signal]
|
|
var first datasource.Value
|
|
if ok {
|
|
first = datasource.Value{Timestamp: cur.ts, Data: cur.value, Quality: datasource.QualityGood}
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
if ok {
|
|
go func() {
|
|
select {
|
|
case ch <- first:
|
|
case <-ctx.Done():
|
|
}
|
|
}()
|
|
}
|
|
|
|
return func() {
|
|
s.mu.Lock()
|
|
if m := s.subs[signal]; m != nil {
|
|
delete(m, id)
|
|
if len(m) == 0 {
|
|
delete(s.subs, signal)
|
|
}
|
|
}
|
|
s.mu.Unlock()
|
|
}, nil
|
|
}
|
|
|
|
func toFloat64(v any) float64 {
|
|
switch x := v.(type) {
|
|
case float64:
|
|
return x
|
|
case float32:
|
|
return float64(x)
|
|
case int:
|
|
return float64(x)
|
|
case int64:
|
|
return float64(x)
|
|
case bool:
|
|
if x {
|
|
return 1
|
|
}
|
|
return 0
|
|
case json.Number:
|
|
f, _ := x.Float64()
|
|
return f
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// Write sets a variable (creating it if needed), persists, and fans the new value
|
|
// out to all subscribers.
|
|
func (s *Source) Write(_ context.Context, signal string, value any) error {
|
|
v := toFloat64(value)
|
|
|
|
s.mu.Lock()
|
|
now := time.Now()
|
|
s.vars[signal] = &variable{value: v, ts: now}
|
|
s.saveLocked()
|
|
subs := make([]chan<- datasource.Value, 0, len(s.subs[signal]))
|
|
for _, ch := range s.subs[signal] {
|
|
subs = append(subs, ch)
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
upd := datasource.Value{Timestamp: now, Data: v, Quality: datasource.QualityGood}
|
|
for _, ch := range subs {
|
|
select {
|
|
case ch <- upd:
|
|
default:
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// History is not supported.
|
|
func (s *Source) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) {
|
|
return nil, datasource.ErrHistoryUnavailable
|
|
}
|