Improving all side of app
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/audit"
|
||||
@@ -34,11 +35,28 @@ type Engine struct {
|
||||
cancel context.CancelFunc // cancels the current generation
|
||||
wg *sync.WaitGroup // tracks the current generation's goroutines
|
||||
|
||||
// notifier delivers action.dialog requests to connected clients. Stored in
|
||||
// an atomic so flow goroutines can read it without taking e.mu (which Reload
|
||||
// holds while it waits for those same goroutines to drain).
|
||||
notifier atomic.Value // notifierBox
|
||||
|
||||
// Shared live signal cache for the current generation (key "ds\0name").
|
||||
liveMu sync.RWMutex
|
||||
live map[string]float64
|
||||
}
|
||||
|
||||
// notifierBox wraps a Notifier so atomic.Value always sees one concrete type.
|
||||
type notifierBox struct{ n Notifier }
|
||||
|
||||
// dialogSeq generates unique action.dialog ids across all graphs.
|
||||
var dialogSeq uint64
|
||||
|
||||
// SetNotifier installs the sink for action.dialog requests. Safe to call once
|
||||
// at startup before or after Reload; it is read lock-free by running flows.
|
||||
func (e *Engine) SetNotifier(n Notifier) {
|
||||
e.notifier.Store(notifierBox{n: n})
|
||||
}
|
||||
|
||||
// NewEngine creates an engine bound to root. Call Reload to start it. rec records
|
||||
// the writes performed by flows; pass audit.Nop() to disable auditing.
|
||||
func NewEngine(root context.Context, brk *broker.Broker, store *Store, rec audit.Recorder, log *slog.Logger) *Engine {
|
||||
@@ -252,6 +270,29 @@ func (e *Engine) write(cg *compiledGraph, target string, val float64) {
|
||||
e.audit.Record(ev)
|
||||
}
|
||||
|
||||
// emitDialog delivers an action.dialog node's request to the installed Notifier
|
||||
// (the WebSocket dialog hub). It is lock-free so it never deadlocks against a
|
||||
// concurrent Reload that is waiting for this flow goroutine to finish.
|
||||
func (e *Engine) emitDialog(n Node) {
|
||||
box, _ := e.notifier.Load().(notifierBox)
|
||||
if box.n == nil {
|
||||
return
|
||||
}
|
||||
kind := strings.TrimSpace(n.param("kind"))
|
||||
if kind != "error" && kind != "input" {
|
||||
kind = "info"
|
||||
}
|
||||
box.n.Notify(Dialog{
|
||||
ID: strconv.FormatUint(atomic.AddUint64(&dialogSeq, 1), 10),
|
||||
Kind: kind,
|
||||
Title: n.param("title"),
|
||||
Message: n.param("message"),
|
||||
Target: strings.TrimSpace(n.param("target")),
|
||||
Users: splitCSV(n.param("users")),
|
||||
Groups: splitCSV(n.param("groups")),
|
||||
})
|
||||
}
|
||||
|
||||
// ── compiled graph ─────────────────────────────────────────────────────────────
|
||||
|
||||
type wireOut struct {
|
||||
@@ -658,6 +699,10 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
|
||||
cg.runLua(node.ID, ctx)
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.dialog":
|
||||
cg.engine.emitDialog(node)
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
default:
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ package controllogic
|
||||
// action.delay — waits `ms` before continuing.
|
||||
// action.log — logs an expression value to the server log.
|
||||
// action.lua — runs a sandboxed Lua script with get/set/log host funcs.
|
||||
// action.dialog — pushes an info/error/input dialog to connected clients
|
||||
// filtered by user/group; input responses write `target`.
|
||||
|
||||
// Node is a single node in a control-logic graph. Params are stored as strings
|
||||
// (matching the panel logic model) and parsed per-kind by the engine.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package controllogic
|
||||
|
||||
import "strings"
|
||||
|
||||
// Dialog is a user-facing notification or input request emitted by an
|
||||
// action.dialog node. It is delivered to connected clients whose identity
|
||||
// matches Users/Groups (both empty = everyone). For an "input" dialog the
|
||||
// client's response is written back to Target (a "ds:name" reference, e.g.
|
||||
// "srv:approved") so control logic can read it on a later activation.
|
||||
type Dialog struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"` // "info" | "error" | "input"
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Target string `json:"target,omitempty"`
|
||||
Users []string `json:"users,omitempty"`
|
||||
Groups []string `json:"groups,omitempty"`
|
||||
}
|
||||
|
||||
// Notifier delivers control-logic dialogs to connected clients. The server
|
||||
// implements it; the engine calls Notify when an action.dialog node runs.
|
||||
// Notify must not block (the hub fans out without waiting on slow clients).
|
||||
type Notifier interface {
|
||||
Notify(Dialog)
|
||||
}
|
||||
|
||||
// splitCSV parses a comma-separated user/group filter into trimmed,
|
||||
// non-empty tokens. An empty string yields a nil slice (no filter).
|
||||
func splitCSV(s string) []string {
|
||||
var out []string
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
if t := strings.TrimSpace(p); t != "" {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -9,6 +9,7 @@ type SignalDef struct {
|
||||
Signal string `json:"signal"` // upstream signal name (or "" for constant)
|
||||
Inputs []InputRef `json:"inputs"` // alternative multi-input format
|
||||
Pipeline []NodeDef `json:"pipeline"` // ordered list of DSP nodes
|
||||
Graph *Graph `json:"graph,omitempty"` // DAG form (preferred when present)
|
||||
Meta MetaOverride `json:"meta"` // optional metadata overrides
|
||||
|
||||
// Visibility controls who sees this signal in the signal tree:
|
||||
@@ -34,6 +35,37 @@ type NodeDef struct {
|
||||
Params map[string]any `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
// Graph is the DAG form of a synthetic signal: a set of nodes (sources, ops and
|
||||
// one output) wired together by explicit per-node ordered input lists. It
|
||||
// supersedes the linear Inputs+Pipeline form: when SignalDef.Graph is set it is
|
||||
// authoritative; otherwise the legacy linear fields are converted into an
|
||||
// equivalent graph at load time (see toGraph).
|
||||
type Graph struct {
|
||||
Nodes []GraphNode `json:"nodes"`
|
||||
Output string `json:"output"` // id of the output node
|
||||
}
|
||||
|
||||
// GraphNode is one node in a Graph.
|
||||
//
|
||||
// kind=="source": carries DS+Signal; has no Inputs (a graph root).
|
||||
// kind=="op": carries Op + Params; Inputs lists upstream node IDs in the
|
||||
// order the op receives them (input 0, 1, … e.g. a−b, a÷b).
|
||||
// kind=="output": Inputs has a single upstream node whose value is the result.
|
||||
//
|
||||
// X/Y are the editor layout coordinates, persisted so a reloaded graph keeps its
|
||||
// shape; they have no effect on evaluation.
|
||||
type GraphNode struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Op string `json:"op,omitempty"`
|
||||
Params map[string]any `json:"params,omitempty"`
|
||||
DS string `json:"ds,omitempty"`
|
||||
Signal string `json:"signal,omitempty"`
|
||||
Inputs []string `json:"inputs,omitempty"`
|
||||
X float64 `json:"x,omitempty"`
|
||||
Y float64 `json:"y,omitempty"`
|
||||
}
|
||||
|
||||
// MetaOverride allows the synthetic signal to override display metadata.
|
||||
type MetaOverride struct {
|
||||
Unit string `json:"unit,omitempty"`
|
||||
|
||||
@@ -38,21 +38,32 @@ func stringParam(params map[string]any, key string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// BuildPipeline converts a []NodeDef (from JSON) into a []dsp.Node ready for
|
||||
// execution. JSON numbers are float64, so all numeric params are handled as
|
||||
// float64 regardless of the final type needed.
|
||||
func BuildPipeline(defs []NodeDef) ([]dsp.Node, error) {
|
||||
nodes := make([]dsp.Node, 0, len(defs))
|
||||
for i, d := range defs {
|
||||
n, err := buildNode(d)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pipeline node %d (%q): %w", i, d.Type, err)
|
||||
}
|
||||
nodes = append(nodes, n)
|
||||
// stringSliceParam extracts a []string from params; JSON arrays decode to []any,
|
||||
// so each element is coerced via its string value. Returns nil if missing.
|
||||
func stringSliceParam(params map[string]any, key string) []string {
|
||||
if params == nil {
|
||||
return nil
|
||||
}
|
||||
return nodes, nil
|
||||
v, ok := params[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
arr, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(arr))
|
||||
for _, e := range arr {
|
||||
if s, ok := e.(string); ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildNode converts a single NodeDef (from JSON) into a dsp.Node ready for
|
||||
// execution. JSON numbers are float64, so all numeric params are handled as
|
||||
// float64 regardless of the final type needed.
|
||||
func buildNode(d NodeDef) (dsp.Node, error) {
|
||||
p := d.Params
|
||||
switch d.Type {
|
||||
@@ -100,7 +111,7 @@ func buildNode(d NodeDef) (dsp.Node, error) {
|
||||
}, nil
|
||||
|
||||
case "expr":
|
||||
return &dsp.ExprNode{Expr: stringParam(p, "expr")}, nil
|
||||
return &dsp.ExprNode{Expr: stringParam(p, "expr"), Vars: stringSliceParam(p, "vars")}, nil
|
||||
|
||||
case "lowpass":
|
||||
order := int(floatParam(p, "order"))
|
||||
@@ -113,7 +124,7 @@ func buildNode(d NodeDef) (dsp.Node, error) {
|
||||
}, nil
|
||||
|
||||
case "lua":
|
||||
return &dsp.LuaNode{Script: stringParam(p, "script")}, nil
|
||||
return &dsp.LuaNode{Script: stringParam(p, "script"), Vars: stringSliceParam(p, "vars")}, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown node type %q", d.Type)
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package synthetic
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/dsp"
|
||||
)
|
||||
|
||||
// runtimeGraph is the executable form of a synthetic signal's DAG. Nodes are
|
||||
// held in topological order so a single forward pass computes every value with
|
||||
// each node's inputs already resolved. Op-node state maps persist across
|
||||
// evaluations (for stateful nodes like moving_average / lua).
|
||||
type runtimeGraph struct {
|
||||
order []*rtNode // topological order (sources first, output last)
|
||||
sources []rtSource // source nodes, in topological order
|
||||
outputID string // id of the output node
|
||||
}
|
||||
|
||||
type rtNode struct {
|
||||
id string
|
||||
kind string // source | op | output
|
||||
op dsp.Node // set for kind==op
|
||||
state map[string]any // persistent per-node state (op only)
|
||||
inputs []string // upstream node ids, in input order
|
||||
}
|
||||
|
||||
type rtSource struct {
|
||||
id string
|
||||
ref broker.SignalRef
|
||||
}
|
||||
|
||||
// sourceRefs returns the broker references for every source node, in a stable
|
||||
// order matching rg.sources.
|
||||
func (rg *runtimeGraph) sourceRefs() []broker.SignalRef {
|
||||
refs := make([]broker.SignalRef, len(rg.sources))
|
||||
for i, s := range rg.sources {
|
||||
refs[i] = s.ref
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
// eval computes the output value given the latest value for each source node
|
||||
// (keyed by source node id). Nodes are visited in topological order so every
|
||||
// input is already present in vals by the time a node is processed.
|
||||
func (rg *runtimeGraph) eval(sourceVals map[string]float64) (float64, error) {
|
||||
vals := make(map[string]float64, len(rg.order))
|
||||
for id, v := range sourceVals {
|
||||
vals[id] = v
|
||||
}
|
||||
for _, n := range rg.order {
|
||||
switch n.kind {
|
||||
case "op":
|
||||
in := make([]float64, len(n.inputs))
|
||||
for i, id := range n.inputs {
|
||||
in[i] = vals[id]
|
||||
}
|
||||
r, err := n.op.Process(in, n.state)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("node %s (%s): %w", n.id, n.op.Type(), err)
|
||||
}
|
||||
vals[n.id] = r
|
||||
case "output":
|
||||
if len(n.inputs) > 0 {
|
||||
vals[n.id] = vals[n.inputs[0]]
|
||||
}
|
||||
}
|
||||
}
|
||||
return vals[rg.outputID], nil
|
||||
}
|
||||
|
||||
// compileGraph converts a SignalDef into an executable runtimeGraph. When the
|
||||
// def carries an explicit Graph it is used directly; otherwise the legacy
|
||||
// Inputs+Pipeline form is converted to an equivalent linear graph (see toGraph).
|
||||
func compileGraph(def SignalDef) (*runtimeGraph, error) {
|
||||
g := toGraph(def)
|
||||
if g == nil || len(g.Nodes) == 0 {
|
||||
return &runtimeGraph{}, nil
|
||||
}
|
||||
order, err := topoOrder(g)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rg := &runtimeGraph{outputID: g.Output}
|
||||
for _, gn := range order {
|
||||
switch gn.Kind {
|
||||
case "source":
|
||||
rg.sources = append(rg.sources, rtSource{id: gn.ID, ref: broker.SignalRef{DS: gn.DS, Name: gn.Signal}})
|
||||
case "op":
|
||||
node, err := buildNode(NodeDef{Type: gn.Op, Params: gn.Params})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("node %q: %w", gn.ID, err)
|
||||
}
|
||||
rg.order = append(rg.order, &rtNode{id: gn.ID, kind: "op", op: node, state: map[string]any{}, inputs: gn.Inputs})
|
||||
case "output":
|
||||
rg.outputID = gn.ID
|
||||
rg.order = append(rg.order, &rtNode{id: gn.ID, kind: "output", inputs: gn.Inputs})
|
||||
default:
|
||||
return nil, fmt.Errorf("node %q: unknown kind %q", gn.ID, gn.Kind)
|
||||
}
|
||||
}
|
||||
return rg, nil
|
||||
}
|
||||
|
||||
// topoOrder returns the graph's nodes in a topological (dependency-first) order,
|
||||
// treating each node's Inputs as its predecessors. It errors on dangling input
|
||||
// references or cycles.
|
||||
func topoOrder(g *Graph) ([]GraphNode, error) {
|
||||
byID := make(map[string]GraphNode, len(g.Nodes))
|
||||
for _, n := range g.Nodes {
|
||||
byID[n.ID] = n
|
||||
}
|
||||
indeg := make(map[string]int, len(g.Nodes))
|
||||
succ := make(map[string][]string, len(g.Nodes))
|
||||
for _, n := range g.Nodes {
|
||||
if _, ok := indeg[n.ID]; !ok {
|
||||
indeg[n.ID] = 0
|
||||
}
|
||||
for _, in := range n.Inputs {
|
||||
if _, ok := byID[in]; !ok {
|
||||
return nil, fmt.Errorf("node %q references unknown input %q", n.ID, in)
|
||||
}
|
||||
indeg[n.ID]++
|
||||
succ[in] = append(succ[in], n.ID)
|
||||
}
|
||||
}
|
||||
// Seed the queue with roots, preserving the node slice order for determinism.
|
||||
queue := make([]string, 0, len(g.Nodes))
|
||||
for _, n := range g.Nodes {
|
||||
if indeg[n.ID] == 0 {
|
||||
queue = append(queue, n.ID)
|
||||
}
|
||||
}
|
||||
order := make([]GraphNode, 0, len(g.Nodes))
|
||||
for len(queue) > 0 {
|
||||
id := queue[0]
|
||||
queue = queue[1:]
|
||||
order = append(order, byID[id])
|
||||
for _, s := range succ[id] {
|
||||
indeg[s]--
|
||||
if indeg[s] == 0 {
|
||||
queue = append(queue, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(order) != len(g.Nodes) {
|
||||
return nil, errors.New("graph contains a cycle")
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
|
||||
// toGraph returns the DAG for a SignalDef. If def.Graph is set it is returned
|
||||
// as-is. Otherwise the legacy linear form is converted: each input signal
|
||||
// becomes a source node, the pipeline becomes a chain of op nodes (the first op
|
||||
// receiving every source, each later op the previous op's output), terminated by
|
||||
// an output node. With no pipeline the output takes the first source directly,
|
||||
// matching the old runPipeline behaviour.
|
||||
func toGraph(def SignalDef) *Graph {
|
||||
if def.Graph != nil && len(def.Graph.Nodes) > 0 {
|
||||
return def.Graph
|
||||
}
|
||||
|
||||
inputs := def.Inputs
|
||||
if len(inputs) == 0 && def.DS != "" && def.Signal != "" {
|
||||
inputs = []InputRef{{DS: def.DS, Signal: def.Signal}}
|
||||
}
|
||||
|
||||
nodes := make([]GraphNode, 0, len(inputs)+len(def.Pipeline)+1)
|
||||
srcIDs := make([]string, 0, len(inputs))
|
||||
for i, inp := range inputs {
|
||||
id := fmt.Sprintf("s%d", i)
|
||||
nodes = append(nodes, GraphNode{ID: id, Kind: "source", DS: inp.DS, Signal: inp.Signal})
|
||||
srcIDs = append(srcIDs, id)
|
||||
}
|
||||
|
||||
opIDs := make([]string, 0, len(def.Pipeline))
|
||||
for i, nd := range def.Pipeline {
|
||||
id := fmt.Sprintf("p%d", i)
|
||||
var ins []string
|
||||
if i == 0 {
|
||||
ins = srcIDs
|
||||
} else {
|
||||
ins = []string{opIDs[i-1]}
|
||||
}
|
||||
nodes = append(nodes, GraphNode{ID: id, Kind: "op", Op: nd.Type, Params: nd.Params, Inputs: ins})
|
||||
opIDs = append(opIDs, id)
|
||||
}
|
||||
|
||||
var outInputs []string
|
||||
if len(opIDs) > 0 {
|
||||
outInputs = []string{opIDs[len(opIDs)-1]}
|
||||
} else if len(srcIDs) > 0 {
|
||||
outInputs = []string{srcIDs[0]}
|
||||
}
|
||||
nodes = append(nodes, GraphNode{ID: "out", Kind: "output", Inputs: outInputs})
|
||||
|
||||
return &Graph{Nodes: nodes, Output: "out"}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package synthetic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// evalDef compiles a SignalDef and evaluates it against per-source values keyed
|
||||
// by source node id.
|
||||
func evalDef(t *testing.T, def SignalDef, srcVals map[string]float64) float64 {
|
||||
t.Helper()
|
||||
rg, err := compileGraph(def)
|
||||
if err != nil {
|
||||
t.Fatalf("compileGraph: %v", err)
|
||||
}
|
||||
out, err := rg.eval(srcVals)
|
||||
if err != nil {
|
||||
t.Fatalf("eval: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestGraphMultiInputDAG verifies that an intermediate op can take two
|
||||
// independently-wired sources — the capability the old linear pipeline lacked.
|
||||
func TestGraphMultiInputDAG(t *testing.T) {
|
||||
def := SignalDef{
|
||||
Name: "diff",
|
||||
Graph: &Graph{
|
||||
Output: "out",
|
||||
Nodes: []GraphNode{
|
||||
{ID: "a", Kind: "source", DS: "x", Signal: "left"},
|
||||
{ID: "b", Kind: "source", DS: "x", Signal: "right"},
|
||||
{ID: "sub", Kind: "op", Op: "subtract", Inputs: []string{"a", "b"}},
|
||||
{ID: "out", Kind: "output", Inputs: []string{"sub"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := evalDef(t, def, map[string]float64{"a": 10, "b": 3})
|
||||
if got != 7 {
|
||||
t.Errorf("subtract DAG: want 7, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGraphExprNamedInputs verifies expr nodes bind arbitrary named inputs in
|
||||
// wired order.
|
||||
func TestGraphExprNamedInputs(t *testing.T) {
|
||||
def := SignalDef{
|
||||
Name: "formula",
|
||||
Graph: &Graph{
|
||||
Output: "out",
|
||||
Nodes: []GraphNode{
|
||||
{ID: "a", Kind: "source", DS: "x", Signal: "p"},
|
||||
{ID: "b", Kind: "source", DS: "x", Signal: "q"},
|
||||
{ID: "e", Kind: "op", Op: "expr", Inputs: []string{"a", "b"},
|
||||
Params: map[string]any{"expr": "price * qty", "vars": []any{"price", "qty"}}},
|
||||
{ID: "out", Kind: "output", Inputs: []string{"e"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := evalDef(t, def, map[string]float64{"a": 4, "b": 2.5})
|
||||
if math.Abs(got-10) > 1e-9 {
|
||||
t.Errorf("expr named inputs: want 10, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGraphFanInToExpr exercises a non-trivial DAG: two ops feeding one expr.
|
||||
func TestGraphFanInToExpr(t *testing.T) {
|
||||
def := SignalDef{
|
||||
Name: "combo",
|
||||
Graph: &Graph{
|
||||
Output: "out",
|
||||
Nodes: []GraphNode{
|
||||
{ID: "a", Kind: "source", DS: "x", Signal: "p"},
|
||||
{ID: "b", Kind: "source", DS: "x", Signal: "q"},
|
||||
{ID: "g", Kind: "op", Op: "gain", Inputs: []string{"a"}, Params: map[string]any{"gain": 2.0}},
|
||||
{ID: "o", Kind: "op", Op: "offset", Inputs: []string{"b"}, Params: map[string]any{"offset": 1.0}},
|
||||
{ID: "e", Kind: "op", Op: "expr", Inputs: []string{"g", "o"}, Params: map[string]any{"expr": "a + b"}},
|
||||
{ID: "out", Kind: "output", Inputs: []string{"e"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
// g = 5*2 = 10 ; o = 4+1 = 5 ; a+b = 15
|
||||
got := evalDef(t, def, map[string]float64{"a": 5, "b": 4})
|
||||
if math.Abs(got-15) > 1e-9 {
|
||||
t.Errorf("fan-in DAG: want 15, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGraphLegacyConversion verifies the linear Inputs+Pipeline form still
|
||||
// evaluates correctly via the graph runtime.
|
||||
func TestGraphLegacyConversion(t *testing.T) {
|
||||
def := SignalDef{
|
||||
Name: "legacy",
|
||||
DS: "x",
|
||||
Signal: "p",
|
||||
Pipeline: []NodeDef{{Type: "gain", Params: map[string]any{"gain": 3.0}}},
|
||||
}
|
||||
rg, err := compileGraph(def)
|
||||
if err != nil {
|
||||
t.Fatalf("compileGraph: %v", err)
|
||||
}
|
||||
if len(rg.sources) != 1 {
|
||||
t.Fatalf("want 1 source, got %d", len(rg.sources))
|
||||
}
|
||||
got, err := rg.eval(map[string]float64{rg.sources[0].id: 4})
|
||||
if err != nil {
|
||||
t.Fatalf("eval: %v", err)
|
||||
}
|
||||
if got != 12 {
|
||||
t.Errorf("legacy gain: want 12, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGraphCycleRejected ensures a cyclic graph is refused at compile time.
|
||||
func TestGraphCycleRejected(t *testing.T) {
|
||||
def := SignalDef{
|
||||
Name: "cyclic",
|
||||
Graph: &Graph{
|
||||
Output: "out",
|
||||
Nodes: []GraphNode{
|
||||
{ID: "a", Kind: "op", Op: "gain", Inputs: []string{"b"}, Params: map[string]any{"gain": 1.0}},
|
||||
{ID: "b", Kind: "op", Op: "gain", Inputs: []string{"a"}, Params: map[string]any{"gain": 1.0}},
|
||||
{ID: "out", Kind: "output", Inputs: []string{"a"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if _, err := compileGraph(def); err == nil {
|
||||
t.Error("expected cycle to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -13,16 +13,14 @@ import (
|
||||
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
"github.com/uopi/uopi/internal/dsp"
|
||||
)
|
||||
|
||||
const definitionsFile = "synthetic.json"
|
||||
|
||||
// signalState holds everything needed to run one synthetic signal.
|
||||
type signalState struct {
|
||||
def SignalDef
|
||||
nodes []dsp.Node
|
||||
states []map[string]any // one map per node, persistent across calls
|
||||
def SignalDef
|
||||
rg *runtimeGraph // compiled DAG; op-node state persists across evaluations
|
||||
|
||||
// cancel stops the goroutine driving this signal.
|
||||
cancel context.CancelFunc
|
||||
@@ -123,19 +121,25 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
|
||||
return nil, datasource.ErrNotFound
|
||||
}
|
||||
|
||||
// Collect the upstream references for this signal.
|
||||
refs := upstreamRefs(st.def)
|
||||
// Collect the source node references for this signal's DAG.
|
||||
refs := st.rg.sourceRefs()
|
||||
if len(refs) == 0 {
|
||||
return nil, fmt.Errorf("synthetic: signal %q has no upstream inputs", signal)
|
||||
}
|
||||
// Source node ids, index-aligned with refs, so updates map to graph inputs.
|
||||
srcIDs := make([]string, len(st.rg.sources))
|
||||
for i, s := range st.rg.sources {
|
||||
srcIDs[i] = s.id
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
go func() {
|
||||
defer cancel()
|
||||
|
||||
// Latest value per upstream input index.
|
||||
latest := make([]float64, len(refs))
|
||||
// Latest value and timestamp per source node id.
|
||||
latest := make(map[string]float64, len(refs))
|
||||
latestTs := make([]time.Time, len(refs))
|
||||
ready := make([]bool, len(refs))
|
||||
|
||||
// Subscribe to every upstream ref via the broker.
|
||||
@@ -184,7 +188,8 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
|
||||
return
|
||||
|
||||
case upd := <-updateCh:
|
||||
latest[upd.idx] = upd.val
|
||||
latest[srcIDs[upd.idx]] = upd.val
|
||||
latestTs[upd.idx] = upd.ts
|
||||
ready[upd.idx] = true
|
||||
|
||||
// Only compute once we have at least one value for every input.
|
||||
@@ -199,7 +204,19 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
|
||||
continue
|
||||
}
|
||||
|
||||
// Run the pipeline.
|
||||
// The output is computed from the latest value of every input, so
|
||||
// its timestamp is the most recent contributing sample time. Using
|
||||
// the triggering update's timestamp instead would drag the output
|
||||
// back in time whenever a slow/stale input fired, producing
|
||||
// non-monotonic or duplicated timestamps on plots.
|
||||
outTs := latestTs[0]
|
||||
for _, ts := range latestTs[1:] {
|
||||
if ts.After(outTs) {
|
||||
outTs = ts
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate the DAG.
|
||||
s.mu.RLock()
|
||||
cur, stillExists := s.signals[signal]
|
||||
s.mu.RUnlock()
|
||||
@@ -207,14 +224,14 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
|
||||
return
|
||||
}
|
||||
|
||||
result, err := runPipeline(cur.nodes, cur.states, latest)
|
||||
result, err := cur.rg.eval(latest)
|
||||
if err != nil {
|
||||
s.log.Warn("synthetic: pipeline error", "signal", signal, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
v := datasource.Value{
|
||||
Timestamp: upd.ts,
|
||||
Timestamp: outTs,
|
||||
Data: result,
|
||||
Quality: datasource.QualityGood,
|
||||
}
|
||||
@@ -246,9 +263,9 @@ func (s *Synthetic) AddSignal(def SignalDef) error {
|
||||
return errors.New("signal name must not be empty")
|
||||
}
|
||||
|
||||
nodes, err := BuildPipeline(def.Pipeline)
|
||||
rg, err := compileGraph(def)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build pipeline: %w", err)
|
||||
return fmt.Errorf("compile graph: %w", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
@@ -257,16 +274,7 @@ func (s *Synthetic) AddSignal(def SignalDef) error {
|
||||
return fmt.Errorf("signal %q already exists", def.Name)
|
||||
}
|
||||
|
||||
states := make([]map[string]any, len(nodes))
|
||||
for i := range states {
|
||||
states[i] = make(map[string]any)
|
||||
}
|
||||
|
||||
st := &signalState{
|
||||
def: def,
|
||||
nodes: nodes,
|
||||
states: states,
|
||||
}
|
||||
st := &signalState{def: def, rg: rg}
|
||||
s.signals[def.Name] = st
|
||||
s.mu.Unlock()
|
||||
|
||||
@@ -320,14 +328,9 @@ func (s *Synthetic) UpdateSignal(def SignalDef) error {
|
||||
return errors.New("signal name must not be empty")
|
||||
}
|
||||
|
||||
nodes, err := BuildPipeline(def.Pipeline)
|
||||
rg, err := compileGraph(def)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build pipeline: %w", err)
|
||||
}
|
||||
|
||||
states := make([]map[string]any, len(nodes))
|
||||
for i := range states {
|
||||
states[i] = make(map[string]any)
|
||||
return fmt.Errorf("compile graph: %w", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
@@ -339,7 +342,7 @@ func (s *Synthetic) UpdateSignal(def SignalDef) error {
|
||||
if old.cancel != nil {
|
||||
old.cancel()
|
||||
}
|
||||
s.signals[def.Name] = &signalState{def: def, nodes: nodes, states: states}
|
||||
s.signals[def.Name] = &signalState{def: def, rg: rg}
|
||||
s.mu.Unlock()
|
||||
|
||||
if err := s.saveDefs(); err != nil {
|
||||
@@ -402,73 +405,22 @@ func (s *Synthetic) saveDefs() error {
|
||||
return os.WriteFile(s.defsFilePath(), data, 0o644)
|
||||
}
|
||||
|
||||
// startSignal builds the pipeline for def and registers the signalState.
|
||||
// startSignal compiles the DAG for def and registers the signalState.
|
||||
// The actual goroutines are started lazily by Subscribe.
|
||||
func (s *Synthetic) startSignal(def SignalDef) error {
|
||||
nodes, err := BuildPipeline(def.Pipeline)
|
||||
rg, err := compileGraph(def)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build pipeline for %q: %w", def.Name, err)
|
||||
}
|
||||
|
||||
states := make([]map[string]any, len(nodes))
|
||||
for i := range states {
|
||||
states[i] = make(map[string]any)
|
||||
return fmt.Errorf("compile graph for %q: %w", def.Name, err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.signals[def.Name] = &signalState{
|
||||
def: def,
|
||||
nodes: nodes,
|
||||
states: states,
|
||||
}
|
||||
s.signals[def.Name] = &signalState{def: def, rg: rg}
|
||||
s.mu.Unlock()
|
||||
|
||||
s.log.Info("synthetic: signal registered", "name", def.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// runPipeline executes all nodes in sequence. The output of node N becomes
|
||||
// input[0] of node N+1. For the first node, inputs is the full upstream slice.
|
||||
func runPipeline(nodes []dsp.Node, states []map[string]any, inputs []float64) (float64, error) {
|
||||
if len(nodes) == 0 {
|
||||
if len(inputs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return inputs[0], nil
|
||||
}
|
||||
|
||||
// First node receives all upstream inputs.
|
||||
cur := inputs
|
||||
var result float64
|
||||
var err error
|
||||
|
||||
for i, node := range nodes {
|
||||
result, err = node.Process(cur, states[i])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("node %d (%s): %w", i, node.Type(), err)
|
||||
}
|
||||
// Subsequent nodes receive only the single output of the previous node.
|
||||
cur = []float64{result}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// upstreamRefs returns the broker.SignalRef list for a SignalDef.
|
||||
// If Inputs is set, those take precedence; otherwise DS+Signal is used.
|
||||
func upstreamRefs(def SignalDef) []broker.SignalRef {
|
||||
if len(def.Inputs) > 0 {
|
||||
refs := make([]broker.SignalRef, len(def.Inputs))
|
||||
for i, inp := range def.Inputs {
|
||||
refs[i] = broker.SignalRef{DS: inp.DS, Name: inp.Signal}
|
||||
}
|
||||
return refs
|
||||
}
|
||||
if def.DS != "" && def.Signal != "" {
|
||||
return []broker.SignalRef{{DS: def.DS, Name: def.Signal}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// defToMetadata converts a SignalDef into a datasource.Metadata.
|
||||
func defToMetadata(def SignalDef) datasource.Metadata {
|
||||
return datasource.Metadata{
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package synthetic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
// seqSource is a test DataSource that emits a fixed sequence of values, each
|
||||
// carrying its own timestamp, so tests can control upstream sample times.
|
||||
type seqSource struct {
|
||||
name string
|
||||
seq []datasource.Value
|
||||
}
|
||||
|
||||
func (s *seqSource) Name() string { return s.name }
|
||||
func (s *seqSource) Connect(context.Context) error { return nil }
|
||||
func (s *seqSource) ListSignals(context.Context) ([]datasource.Metadata, error) { return nil, nil }
|
||||
func (s *seqSource) GetMetadata(context.Context, string) (datasource.Metadata, error) {
|
||||
return datasource.Metadata{Name: "x", Type: datasource.TypeFloat64}, nil
|
||||
}
|
||||
func (s *seqSource) Write(context.Context, string, any) error { return datasource.ErrNotWritable }
|
||||
func (s *seqSource) History(context.Context, string, time.Time, time.Time, int) ([]datasource.Value, error) {
|
||||
return nil, datasource.ErrHistoryUnavailable
|
||||
}
|
||||
func (s *seqSource) Subscribe(ctx context.Context, _ string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
|
||||
go func() {
|
||||
for _, v := range s.seq {
|
||||
select {
|
||||
case ch <- v:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
time.Sleep(8 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
return func() {}, nil
|
||||
}
|
||||
|
||||
// TestSubscribePreservesUpstreamTimestamp verifies a single-source synthetic
|
||||
// emits each computed value with the upstream sample's timestamp.
|
||||
func TestSubscribePreservesUpstreamTimestamp(t *testing.T) {
|
||||
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
base := time.Date(2026, 6, 19, 10, 0, 0, 0, time.UTC)
|
||||
src := &seqSource{name: "src", seq: []datasource.Value{
|
||||
{Timestamp: base.Add(1 * time.Second), Data: 1.0, Quality: datasource.QualityGood},
|
||||
{Timestamp: base.Add(2 * time.Second), Data: 2.0, Quality: datasource.QualityGood},
|
||||
{Timestamp: base.Add(3 * time.Second), Data: 3.0, Quality: datasource.QualityGood},
|
||||
}}
|
||||
|
||||
brk := broker.New(ctx, log)
|
||||
brk.Register(src)
|
||||
syn := New(t.TempDir(), brk, log)
|
||||
if err := syn.Connect(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := syn.AddSignal(SignalDef{
|
||||
Name: "g", DS: "src", Signal: "x",
|
||||
Pipeline: []NodeDef{{Type: "gain", Params: map[string]any{"gain": 10.0}}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ch := make(chan datasource.Value, 8)
|
||||
if _, err := syn.Subscribe(ctx, "g", ch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := []time.Time{base.Add(1 * time.Second), base.Add(2 * time.Second), base.Add(3 * time.Second)}
|
||||
for i, w := range want {
|
||||
select {
|
||||
case v := <-ch:
|
||||
if !v.Timestamp.Equal(w) {
|
||||
t.Errorf("emit #%d timestamp: want %s, got %s", i, w, v.Timestamp)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("timeout waiting for emit #%d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubscribeMultiSourceUsesLatestTimestamp verifies that a synthetic combining
|
||||
// two independent sources stamps each output with the MOST RECENT contributing
|
||||
// sample time — not the timestamp of whichever source happened to trigger the
|
||||
// computation. A slow source carrying a stale timestamp must not drag the output
|
||||
// backwards in time (which previously produced wrong/non-monotonic plot points).
|
||||
func TestSubscribeMultiSourceUsesLatestTimestamp(t *testing.T) {
|
||||
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
now := time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC)
|
||||
// Fast source A with current timestamps.
|
||||
a := &seqSource{name: "A", seq: []datasource.Value{
|
||||
{Timestamp: now.Add(10 * time.Second), Data: 1.0, Quality: datasource.QualityGood},
|
||||
{Timestamp: now.Add(11 * time.Second), Data: 2.0, Quality: datasource.QualityGood},
|
||||
{Timestamp: now.Add(12 * time.Second), Data: 3.0, Quality: datasource.QualityGood},
|
||||
{Timestamp: now.Add(13 * time.Second), Data: 4.0, Quality: datasource.QualityGood},
|
||||
}}
|
||||
// Slow source B: a single sample with a much older timestamp.
|
||||
b := &seqSource{name: "B", seq: []datasource.Value{
|
||||
{Timestamp: now.Add(1 * time.Second), Data: 100.0, Quality: datasource.QualityGood},
|
||||
}}
|
||||
|
||||
brk := broker.New(ctx, log)
|
||||
brk.Register(a)
|
||||
brk.Register(b)
|
||||
syn := New(t.TempDir(), brk, log)
|
||||
if err := syn.Connect(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := syn.AddSignal(SignalDef{
|
||||
Name: "diff",
|
||||
Graph: &Graph{Output: "out", Nodes: []GraphNode{
|
||||
{ID: "sa", Kind: "source", DS: "A", Signal: "x"},
|
||||
{ID: "sb", Kind: "source", DS: "B", Signal: "x"},
|
||||
{ID: "sub", Kind: "op", Op: "subtract", Inputs: []string{"sa", "sb"}},
|
||||
{ID: "out", Kind: "output", Inputs: []string{"sub"}},
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ch := make(chan datasource.Value, 16)
|
||||
if _, err := syn.Subscribe(ctx, "diff", ch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var last time.Time
|
||||
for i := 0; i < 4; i++ {
|
||||
select {
|
||||
case v := <-ch:
|
||||
// The stale source-B timestamp (t=1s) must never be used: every output
|
||||
// is stamped with the newest input time, so emits stay monotonic.
|
||||
if v.Timestamp.Equal(now.Add(1 * time.Second)) {
|
||||
t.Errorf("emit #%d used the stale source-B timestamp %s", i, v.Timestamp)
|
||||
}
|
||||
if !last.IsZero() && v.Timestamp.Before(last) {
|
||||
t.Errorf("emit #%d went backwards: %s before previous %s", i, v.Timestamp, last)
|
||||
}
|
||||
last = v.Timestamp
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("timeout waiting for emit #%d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-10
@@ -277,16 +277,28 @@ func (n *ThresholdNode) Process(inputs []float64, _ map[string]any) (float64, er
|
||||
|
||||
// ── ExprNode ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// ExprNode evaluates a simple arithmetic expression with variables a, b, c, d
|
||||
// bound to inputs[0..3]. It uses a hand-written recursive descent parser.
|
||||
// ExprNode evaluates a simple arithmetic expression. Inputs are bound to named
|
||||
// variables: Vars[i] -> inputs[i]. When Vars is empty it defaults to a, b, c, d
|
||||
// (bound to inputs[0..3]) for backward compatibility. It uses a hand-written
|
||||
// recursive descent parser.
|
||||
type ExprNode struct {
|
||||
Expr string
|
||||
Vars []string
|
||||
}
|
||||
|
||||
// defaultVarNames returns the variable names for an expr/lua node: the explicit
|
||||
// list when set, otherwise the legacy a,b,c,d.
|
||||
func defaultVarNames(vars []string) []string {
|
||||
if len(vars) > 0 {
|
||||
return vars
|
||||
}
|
||||
return []string{"a", "b", "c", "d"}
|
||||
}
|
||||
|
||||
func (n *ExprNode) Type() string { return "expr" }
|
||||
func (n *ExprNode) Process(inputs []float64, _ map[string]any) (float64, error) {
|
||||
vars := map[string]float64{}
|
||||
names := []string{"a", "b", "c", "d"}
|
||||
names := defaultVarNames(n.Vars)
|
||||
for i, name := range names {
|
||||
if i < len(inputs) {
|
||||
vars[name] = inputs[i]
|
||||
@@ -507,13 +519,10 @@ func (p *exprParser) parseCall() (float64, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Not a function call — must be a single-letter variable.
|
||||
if len(name) != 1 {
|
||||
return 0, fmt.Errorf("unknown identifier %q (use a–d for variables, or a known function name)", name)
|
||||
}
|
||||
// Not a function call — must be a declared input variable.
|
||||
val, ok := p.vars[name]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unknown variable %q (allowed: a, b, c, d)", name)
|
||||
return 0, fmt.Errorf("unknown variable %q (declare it as a named input)", name)
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
@@ -628,10 +637,12 @@ func (n *LowPassNode) Process(inputs []float64, state map[string]any) (float64,
|
||||
// ── LuaNode ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// LuaNode runs a Lua script in a sandboxed gopher-lua VM.
|
||||
// Inputs are bound to globals a, b, c, d. The script's return value is the output.
|
||||
// Inputs are bound to globals named by Vars (Vars[i] -> inputs[i]); when Vars is
|
||||
// empty it defaults to a, b, c, d. The script's return value is the output.
|
||||
// The os, io, package, and debug libraries are disabled.
|
||||
type LuaNode struct {
|
||||
Script string
|
||||
Vars []string
|
||||
}
|
||||
|
||||
func (n *LuaNode) Type() string { return "lua" }
|
||||
@@ -682,7 +693,7 @@ func (n *LuaNode) Process(inputs []float64, state map[string]any) (result float6
|
||||
L.SetTop(0)
|
||||
|
||||
// Bind inputs.
|
||||
names := []string{"a", "b", "c", "d"}
|
||||
names := defaultVarNames(n.Vars)
|
||||
for i, name := range names {
|
||||
if i < len(inputs) {
|
||||
L.SetGlobal(name, lua.LNumber(inputs[i]))
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/uopi/uopi/internal/access"
|
||||
"github.com/uopi/uopi/internal/audit"
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/controllogic"
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
// DialogHub fans control-logic dialog requests out to connected WebSocket
|
||||
// clients whose identity matches the dialog's user/group filter, and routes
|
||||
// input responses back to the dialog's target server variable.
|
||||
//
|
||||
// It implements controllogic.Notifier; the engine calls Notify when an
|
||||
// action.dialog node runs. Input dialogs are remembered as pending so a later
|
||||
// dialogResponse can be correlated by id and validated against its recipient
|
||||
// filter — this is why panels can write the response target even though direct
|
||||
// srv writes are otherwise gated to control-logic editors.
|
||||
type DialogHub struct {
|
||||
broker *broker.Broker
|
||||
policy *access.Policy
|
||||
audit audit.Recorder
|
||||
log *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
clients map[*wsClient]struct{}
|
||||
pending map[string]controllogic.Dialog // input dialogs awaiting a response
|
||||
}
|
||||
|
||||
// NewDialogHub builds an empty hub. rec is never nil after construction.
|
||||
func NewDialogHub(brk *broker.Broker, policy *access.Policy, rec audit.Recorder, log *slog.Logger) *DialogHub {
|
||||
if rec == nil {
|
||||
rec = audit.Nop()
|
||||
}
|
||||
return &DialogHub{
|
||||
broker: brk,
|
||||
policy: policy,
|
||||
audit: rec,
|
||||
log: log,
|
||||
clients: map[*wsClient]struct{}{},
|
||||
pending: map[string]controllogic.Dialog{},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DialogHub) add(c *wsClient) {
|
||||
h.mu.Lock()
|
||||
h.clients[c] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *DialogHub) remove(c *wsClient) {
|
||||
h.mu.Lock()
|
||||
delete(h.clients, c)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// dialogOut is the client-bound JSON form of a dialog request.
|
||||
type dialogOut struct {
|
||||
Type string `json:"type"` // always "dialog"
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"` // "info" | "error" | "input"
|
||||
Title string `json:"title,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// Notify implements controllogic.Notifier: serialise the dialog and push it to
|
||||
// every connected client matching its user/group filter.
|
||||
func (h *DialogHub) Notify(d controllogic.Dialog) {
|
||||
b, err := json.Marshal(dialogOut{
|
||||
Type: "dialog",
|
||||
ID: d.ID,
|
||||
Kind: d.Kind,
|
||||
Title: d.Title,
|
||||
Message: d.Message,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
if d.Kind == "input" && strings.TrimSpace(d.Target) != "" {
|
||||
h.pending[d.ID] = d
|
||||
}
|
||||
var targets []*wsClient
|
||||
for c := range h.clients {
|
||||
if h.matches(c.user, d) {
|
||||
targets = append(targets, c)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
for _, c := range targets {
|
||||
select {
|
||||
case c.outCh <- b:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// matches reports whether user is a recipient of d. An empty user+group filter
|
||||
// targets everyone; otherwise the user must be named or in a named group.
|
||||
func (h *DialogHub) matches(user string, d controllogic.Dialog) bool {
|
||||
if len(d.Users) == 0 && len(d.Groups) == 0 {
|
||||
return true
|
||||
}
|
||||
if h.policy != nil {
|
||||
user = h.policy.ResolveUser(user)
|
||||
}
|
||||
for _, u := range d.Users {
|
||||
if u == user {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if len(d.Groups) > 0 && h.policy != nil {
|
||||
groups := map[string]bool{}
|
||||
for _, g := range h.policy.GroupsOf(user) {
|
||||
groups[g] = true
|
||||
}
|
||||
for _, g := range d.Groups {
|
||||
if groups[g] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// cancel drops a pending input dialog without writing (user dismissed it).
|
||||
func (h *DialogHub) cancel(id string) {
|
||||
h.mu.Lock()
|
||||
delete(h.pending, id)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// respond writes an input dialog's response to its target server variable. The
|
||||
// dialog must be pending and the responding client must have been a recipient;
|
||||
// this gate replaces the usual srv write-permission check for sanctioned
|
||||
// control-logic responses.
|
||||
func (h *DialogHub) respond(ctx context.Context, c *wsClient, id string, value float64) {
|
||||
h.mu.Lock()
|
||||
d, ok := h.pending[id]
|
||||
if ok {
|
||||
delete(h.pending, id)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if !ok || !h.matches(c.user, d) {
|
||||
return
|
||||
}
|
||||
|
||||
ds, name, ok := parseDialogTarget(d.Target)
|
||||
if !ok || ds == "local" {
|
||||
return
|
||||
}
|
||||
src, ok := h.broker.Source(ds)
|
||||
if !ok {
|
||||
h.log.Warn("dialog response: unknown data source", "ds", ds, "target", d.Target)
|
||||
return
|
||||
}
|
||||
|
||||
ev := audit.Event{
|
||||
Actor: c.user,
|
||||
ActorType: audit.ActorUser,
|
||||
Action: "signal.write",
|
||||
DS: ds,
|
||||
Signal: name,
|
||||
Value: strconv.FormatFloat(value, 'g', -1, 64),
|
||||
Detail: "control logic dialog response",
|
||||
IP: c.ip,
|
||||
Outcome: audit.OutcomeOK,
|
||||
}
|
||||
wctx := datasource.WithUser(ctx, c.user)
|
||||
if err := src.Write(wctx, name, value); err != nil {
|
||||
h.log.Warn("dialog response: write failed", "ds", ds, "signal", name, "err", err)
|
||||
ev.Outcome = audit.OutcomeError
|
||||
ev.Error = err.Error()
|
||||
}
|
||||
h.audit.Record(ev)
|
||||
}
|
||||
|
||||
// parseDialogTarget splits a "ds:name" dialog target on the first ':'. A bare
|
||||
// name (no ':') defaults to the persistent server-variable source "srv".
|
||||
func parseDialogTarget(t string) (ds, name string, ok bool) {
|
||||
t = strings.TrimSpace(t)
|
||||
if t == "" {
|
||||
return "", "", false
|
||||
}
|
||||
if i := strings.IndexByte(t, ':'); i >= 0 {
|
||||
return t[:i], t[i+1:], true
|
||||
}
|
||||
return "srv", t, true
|
||||
}
|
||||
@@ -28,7 +28,7 @@ type Server struct {
|
||||
|
||||
// New creates the HTTP server, registers all routes, and returns a ready-to-start Server.
|
||||
// synth may be nil if the synthetic data source is not enabled.
|
||||
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, rec audit.Recorder, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server {
|
||||
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, dialogs *DialogHub, rec audit.Recorder, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server {
|
||||
if rec == nil {
|
||||
rec = audit.Nop()
|
||||
}
|
||||
@@ -41,7 +41,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
|
||||
})
|
||||
|
||||
// WebSocket endpoint
|
||||
mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy, audit: rec})
|
||||
mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy, audit: rec, dialogs: dialogs})
|
||||
|
||||
// Prometheus-format metrics
|
||||
mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions))
|
||||
|
||||
+36
-2
@@ -34,6 +34,9 @@ type inMsg struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Value json.RawMessage `json:"value,omitempty"`
|
||||
|
||||
// dialogResponse — id of the control-logic dialog being answered.
|
||||
ID string `json:"id,omitempty"`
|
||||
|
||||
// history
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
@@ -101,6 +104,8 @@ type wsHandler struct {
|
||||
policy *access.Policy
|
||||
// audit records signal writes (never nil; audit.Nop when disabled).
|
||||
audit audit.Recorder
|
||||
// dialogs fans control-logic dialogs to clients and routes responses.
|
||||
dialogs *DialogHub
|
||||
}
|
||||
|
||||
func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -143,12 +148,19 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
ip: clientIP,
|
||||
policy: h.policy,
|
||||
audit: rec,
|
||||
dialogs: h.dialogs,
|
||||
outCh: make(chan []byte, 512),
|
||||
updateCh: make(chan broker.Update, 1024),
|
||||
subs: make(map[broker.SignalRef]func()),
|
||||
log: h.log,
|
||||
}
|
||||
|
||||
// Register for control-logic dialog pushes for this client's identity.
|
||||
if h.dialogs != nil {
|
||||
h.dialogs.add(c)
|
||||
defer h.dialogs.remove(c)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(3)
|
||||
go func() { defer wg.Done(); c.readLoop(ctx, cancel) }()
|
||||
@@ -174,8 +186,9 @@ type wsClient struct {
|
||||
log *slog.Logger
|
||||
user string // end-user identity from the trusted proxy header ("" if none)
|
||||
ip string // client address, for audit attribution
|
||||
policy *access.Policy // global access-level enforcement
|
||||
audit audit.Recorder // signal-write audit recorder (never nil)
|
||||
policy *access.Policy // global access-level enforcement
|
||||
audit audit.Recorder // signal-write audit recorder (never nil)
|
||||
dialogs *DialogHub // control-logic dialog fan-out (nil if disabled)
|
||||
|
||||
outCh chan []byte // serialised outgoing messages
|
||||
updateCh chan broker.Update // raw updates from the broker
|
||||
@@ -272,6 +285,8 @@ func (c *wsClient) handleMessage(ctx context.Context, data []byte) {
|
||||
c.handleWrite(ctx, msg)
|
||||
case "history":
|
||||
c.handleHistory(ctx, msg)
|
||||
case "dialogResponse":
|
||||
c.handleDialogResponse(ctx, msg)
|
||||
default:
|
||||
c.sendError(ctx, "UNKNOWN_TYPE", "unknown message type: "+msg.Type)
|
||||
}
|
||||
@@ -389,6 +404,25 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
|
||||
c.audit.Record(ev)
|
||||
}
|
||||
|
||||
// handleDialogResponse routes the answer to a control-logic input dialog. An
|
||||
// empty/null value means the user dismissed the dialog (drop it without
|
||||
// writing); otherwise the numeric value is written to the dialog's target.
|
||||
func (c *wsClient) handleDialogResponse(ctx context.Context, msg inMsg) {
|
||||
if c.dialogs == nil || msg.ID == "" {
|
||||
return
|
||||
}
|
||||
if len(msg.Value) == 0 || string(msg.Value) == "null" {
|
||||
c.dialogs.cancel(msg.ID)
|
||||
return
|
||||
}
|
||||
var num float64
|
||||
if err := json.Unmarshal(msg.Value, &num); err != nil {
|
||||
c.dialogs.cancel(msg.ID)
|
||||
return
|
||||
}
|
||||
c.dialogs.respond(ctx, c, msg.ID, num)
|
||||
}
|
||||
|
||||
func (c *wsClient) handleHistory(ctx context.Context, msg inMsg) {
|
||||
metrics.IncHistoryReqs()
|
||||
ds, ok := c.broker.Source(msg.DS)
|
||||
|
||||
Reference in New Issue
Block a user