519c1f2df4
Rewrites internal/controllogic/expr.go to evaluate to Value (scalar float64 or []Value array): adds array literals [a,b], postfix indexing arr[i], and array functions (len, sum, mean, push, pop, slice, concat, sort, …). Resolver type changes from func(...) float64 to func(...) Value; EvalValue added; EvalExpr/EvalBool retain scalar float64/bool returns. Three temporary shims added in engine.go, lua.go, and expr_test.go pending Tasks 4 and 6. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1108 lines
29 KiB
Go
1108 lines
29 KiB
Go
package controllogic
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"math"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/uopi/uopi/internal/audit"
|
|
"github.com/uopi/uopi/internal/broker"
|
|
"github.com/uopi/uopi/internal/confmgr"
|
|
)
|
|
|
|
// errUnknownSource is returned by config-apply writes targeting a data source
|
|
// that isn't registered with the broker.
|
|
var errUnknownSource = errors.New("unknown data source")
|
|
|
|
// formatAny renders a config value for the audit log.
|
|
func formatAny(v any) string {
|
|
switch x := v.(type) {
|
|
case float64:
|
|
return strconv.FormatFloat(x, 'g', -1, 64)
|
|
case string:
|
|
return x
|
|
default:
|
|
return fmt.Sprintf("%v", v)
|
|
}
|
|
}
|
|
|
|
// Guards against runaway flows (cycles / pathological loops).
|
|
const (
|
|
maxSteps = 100000
|
|
maxLoop = 100000
|
|
)
|
|
|
|
// Engine runs all enabled control-logic graphs continuously under a root
|
|
// context. Reload tears down the current generation (subscriptions, timers,
|
|
// in-flight flows) and rebuilds from the store's enabled graphs.
|
|
type Engine struct {
|
|
broker *broker.Broker
|
|
store *Store
|
|
cfg *confmgr.Store
|
|
audit audit.Recorder
|
|
log *slog.Logger
|
|
root context.Context
|
|
|
|
mu sync.Mutex
|
|
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
|
|
|
|
// debugObs receives per-node execution events for the live debug view, and
|
|
// debugWatch is the set of graph ids with at least one live subscriber. Both
|
|
// are read lock-free by flow goroutines (same trick as notifier); emitDebug
|
|
// short-circuits cheaply when the firing graph has no watcher.
|
|
debugObs atomic.Value // debugObsBox
|
|
debugWatch atomic.Value // map[string]bool (immutable snapshot)
|
|
|
|
// fireChs maps a debug route id (live graph id or simulate sandbox id) to its
|
|
// compiled graph's manual-fire channel, so the debug UI can force a trigger to
|
|
// run. Entries are added/removed as generations (and simulate sandboxes) come
|
|
// and go; FireTrigger does a non-blocking send so a torn-down graph drops.
|
|
fireMu sync.Mutex
|
|
fireChs map[string]chan string
|
|
|
|
// 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, cfg *confmgr.Store, rec audit.Recorder, log *slog.Logger) *Engine {
|
|
if rec == nil {
|
|
rec = audit.Nop()
|
|
}
|
|
return &Engine{
|
|
broker: brk,
|
|
store: store,
|
|
cfg: cfg,
|
|
audit: rec,
|
|
log: log,
|
|
root: root,
|
|
live: map[string]float64{},
|
|
fireChs: map[string]chan string{},
|
|
}
|
|
}
|
|
|
|
// ── reference / value helpers ──────────────────────────────────────────────────
|
|
|
|
func refKey(ds, name string) string { return ds + "\x00" + name }
|
|
|
|
// parseRef splits a "ds:name" target on the FIRST ':' (EPICS PV names contain
|
|
// ':'). A bare name (no ':') is a graph-local variable in data source "local".
|
|
func parseRef(target string) (ds, name string, ok bool) {
|
|
t := strings.TrimSpace(target)
|
|
if t == "" {
|
|
return "", "", false
|
|
}
|
|
i := strings.IndexByte(t, ':')
|
|
if i < 0 {
|
|
return "local", t, true
|
|
}
|
|
return t[:i], t[i+1:], true
|
|
}
|
|
|
|
func toNum(v any) float64 {
|
|
switch x := v.(type) {
|
|
case float64:
|
|
return x
|
|
case float32:
|
|
return float64(x)
|
|
case int64:
|
|
return float64(x)
|
|
case int:
|
|
return float64(x)
|
|
case bool:
|
|
if x {
|
|
return 1
|
|
}
|
|
return 0
|
|
case string:
|
|
f, err := strconv.ParseFloat(strings.TrimSpace(x), 64)
|
|
if err != nil {
|
|
return math.NaN()
|
|
}
|
|
return f
|
|
default:
|
|
return math.NaN()
|
|
}
|
|
}
|
|
|
|
// ── lifecycle ───────────────────────────────────────────────────────────────
|
|
|
|
// Reload rebuilds the engine from the store. Safe to call repeatedly (after any
|
|
// graph mutation). It is a no-op-safe full restart of the running generation.
|
|
func (e *Engine) Reload() {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
|
|
// Tear down the previous generation and wait for its goroutines to exit.
|
|
if e.cancel != nil {
|
|
e.cancel()
|
|
e.cancel = nil
|
|
}
|
|
if e.wg != nil {
|
|
e.wg.Wait()
|
|
e.wg = nil
|
|
}
|
|
|
|
e.liveMu.Lock()
|
|
e.live = map[string]float64{}
|
|
e.liveMu.Unlock()
|
|
|
|
graphs := e.store.List()
|
|
var compiled []*compiledGraph
|
|
refs := map[string]RefLite{}
|
|
for i := range graphs {
|
|
g := graphs[i]
|
|
if !g.Enabled {
|
|
continue
|
|
}
|
|
cg := compile(g)
|
|
compiled = append(compiled, cg)
|
|
for k, r := range cg.refs {
|
|
refs[k] = r
|
|
}
|
|
}
|
|
if len(compiled) == 0 {
|
|
return
|
|
}
|
|
|
|
genCtx, cancel := context.WithCancel(e.root)
|
|
wg := &sync.WaitGroup{}
|
|
e.cancel = cancel
|
|
e.wg = wg
|
|
|
|
for _, cg := range compiled {
|
|
cg.engine = e
|
|
cg.genCtx = genCtx
|
|
cg.wg = wg
|
|
e.registerFire(cg.id, cg.fireCh)
|
|
}
|
|
|
|
// One shared updates channel feeds a single dispatch goroutine; every
|
|
// subscription delivers into it. Subscriptions are released on teardown.
|
|
updates := make(chan broker.Update, 128)
|
|
var unsubs []func()
|
|
for _, r := range refs {
|
|
unsub, err := e.broker.Subscribe(broker.SignalRef{DS: r.DS, Name: r.Name}, updates)
|
|
if err != nil {
|
|
e.log.Warn("control logic: subscribe failed", "ds", r.DS, "signal", r.Name, "err", err)
|
|
continue
|
|
}
|
|
unsubs = append(unsubs, unsub)
|
|
}
|
|
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
<-genCtx.Done()
|
|
for _, u := range unsubs {
|
|
u()
|
|
}
|
|
for _, cg := range compiled {
|
|
e.unregisterFire(cg.id, cg.fireCh)
|
|
}
|
|
}()
|
|
|
|
// Dispatch goroutine: keep the live cache fresh and drive level/edge triggers.
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for {
|
|
select {
|
|
case u := <-updates:
|
|
val := toNum(u.Value.Data)
|
|
key := refKey(u.Ref.DS, u.Ref.Name)
|
|
e.liveMu.Lock()
|
|
e.live[key] = val
|
|
e.liveMu.Unlock()
|
|
for _, cg := range compiled {
|
|
cg.onSignal(key, val)
|
|
}
|
|
case <-genCtx.Done():
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Start timer and cron triggers.
|
|
for _, cg := range compiled {
|
|
cg.startTriggers()
|
|
}
|
|
|
|
e.log.Info("control logic engine reloaded", "graphs", len(compiled), "signals", len(refs))
|
|
}
|
|
|
|
// liveGet reads the current value of a signal from the shared cache.
|
|
func (e *Engine) liveGet(ds, name string) float64 {
|
|
if ds == "sys" {
|
|
if name == "time" {
|
|
return float64(time.Now().UnixNano()) / 1e9
|
|
}
|
|
return math.NaN() // sys:dt handled per-activation
|
|
}
|
|
e.liveMu.RLock()
|
|
defer e.liveMu.RUnlock()
|
|
v, ok := e.live[refKey(ds, name)]
|
|
if !ok {
|
|
return math.NaN()
|
|
}
|
|
return v
|
|
}
|
|
|
|
// write applies an action.write/lua-set to a target: a bare name updates a
|
|
// graph-local var; a ds:name target writes to the data source.
|
|
func (e *Engine) write(cg *compiledGraph, target string, val float64) {
|
|
ds, name, ok := parseRef(target)
|
|
if !ok || math.IsNaN(val) {
|
|
return
|
|
}
|
|
if ds == "local" {
|
|
cg.setLocal(name, val)
|
|
return
|
|
}
|
|
if cg.dryRun {
|
|
return // simulate: no real data-source write
|
|
}
|
|
src, ok := e.broker.Source(ds)
|
|
if !ok {
|
|
e.log.Warn("control logic: write to unknown data source", "ds", ds, "signal", name)
|
|
return
|
|
}
|
|
ev := audit.Event{
|
|
Actor: cg.name,
|
|
ActorType: audit.ActorSystem,
|
|
Action: "signal.write",
|
|
DS: ds,
|
|
Signal: name,
|
|
Value: strconv.FormatFloat(val, 'g', -1, 64),
|
|
Detail: "control logic: " + cg.name,
|
|
Outcome: audit.OutcomeOK,
|
|
}
|
|
if err := src.Write(e.root, name, val); err != nil {
|
|
e.log.Warn("control logic: write failed", "ds", ds, "signal", name, "err", err)
|
|
ev.Outcome = audit.OutcomeError
|
|
ev.Error = err.Error()
|
|
}
|
|
e.audit.Record(ev)
|
|
}
|
|
|
|
// applyConfig resolves a config instance + its set and writes every parameter
|
|
// to its target signal via the owning data source (confmgr.Apply). Each write is
|
|
// audited. A bare instance id or a missing config store is a no-op (logged).
|
|
func (e *Engine) applyConfig(cg *compiledGraph, instanceID string) {
|
|
if e.cfg == nil || instanceID == "" {
|
|
return
|
|
}
|
|
inst, err := e.cfg.GetInstance(instanceID)
|
|
if err != nil {
|
|
e.log.Warn("control logic: config apply: unknown instance", "instance", instanceID, "err", err)
|
|
return
|
|
}
|
|
set, err := e.cfg.SetForInstance(inst)
|
|
if err != nil {
|
|
e.log.Warn("control logic: config apply: set lookup failed", "instance", instanceID, "err", err)
|
|
return
|
|
}
|
|
write := func(ds, signal string, value any) error {
|
|
ev := audit.Event{
|
|
Actor: cg.name,
|
|
ActorType: audit.ActorSystem,
|
|
Action: "signal.write",
|
|
DS: ds,
|
|
Signal: signal,
|
|
Value: formatAny(value),
|
|
Detail: "control logic config apply: " + inst.Name,
|
|
Outcome: audit.OutcomeOK,
|
|
}
|
|
var werr error
|
|
if ds == "local" {
|
|
cg.setLocal(signal, toNum(value))
|
|
} else if src, ok := e.broker.Source(ds); ok {
|
|
werr = src.Write(e.root, signal, value)
|
|
} else {
|
|
werr = errUnknownSource
|
|
}
|
|
if werr != nil {
|
|
ev.Outcome = audit.OutcomeError
|
|
ev.Error = werr.Error()
|
|
}
|
|
e.audit.Record(ev)
|
|
return werr
|
|
}
|
|
confmgr.Apply(set, inst, write)
|
|
}
|
|
|
|
// readConfigParam resolves a single parameter's value from a config instance,
|
|
// coercing it to float64 for use as a control-logic value. Returns ok=false if
|
|
// the store/instance/param is missing or the value isn't numeric.
|
|
func (e *Engine) readConfigParam(instanceID, key string) (float64, bool) {
|
|
if e.cfg == nil || instanceID == "" || key == "" {
|
|
return 0, false
|
|
}
|
|
inst, err := e.cfg.GetInstance(instanceID)
|
|
if err != nil {
|
|
e.log.Warn("control logic: config read: unknown instance", "instance", instanceID, "err", err)
|
|
return 0, false
|
|
}
|
|
set, err := e.cfg.SetForInstance(inst)
|
|
if err != nil {
|
|
e.log.Warn("control logic: config read: set lookup failed", "instance", instanceID, "err", err)
|
|
return 0, false
|
|
}
|
|
for _, p := range set.Parameters {
|
|
if p.Key != key {
|
|
continue
|
|
}
|
|
v, ok := inst.Resolve(p)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
f := toNum(v)
|
|
if math.IsNaN(f) {
|
|
return 0, false
|
|
}
|
|
return f, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// writeConfigParam sets a single parameter's value on a config instance and
|
|
// saves a new revision (git-style). The value is coerced to the parameter's
|
|
// declared type (numeric/bool/string) so it validates against the set. A
|
|
// missing store/instance/param is a no-op (logged). Audited.
|
|
func (e *Engine) writeConfigParam(cg *compiledGraph, instanceID, key string, val float64) {
|
|
if e.cfg == nil || instanceID == "" || key == "" {
|
|
return
|
|
}
|
|
inst, err := e.cfg.GetInstance(instanceID)
|
|
if err != nil {
|
|
e.log.Warn("control logic: config write: unknown instance", "instance", instanceID, "err", err)
|
|
return
|
|
}
|
|
set, err := e.cfg.SetForInstance(inst)
|
|
if err != nil {
|
|
e.log.Warn("control logic: config write: set lookup failed", "instance", instanceID, "err", err)
|
|
return
|
|
}
|
|
var param *confmgr.Parameter
|
|
for i := range set.Parameters {
|
|
if set.Parameters[i].Key == key {
|
|
param = &set.Parameters[i]
|
|
break
|
|
}
|
|
}
|
|
if param == nil {
|
|
e.log.Warn("control logic: config write: unknown parameter", "instance", instanceID, "key", key)
|
|
return
|
|
}
|
|
if inst.Values == nil {
|
|
inst.Values = map[string]any{}
|
|
}
|
|
inst.Values[key] = coerceParamValue(*param, val)
|
|
ev := audit.Event{
|
|
Actor: cg.name,
|
|
ActorType: audit.ActorSystem,
|
|
Action: "config.instance.update",
|
|
Detail: "control logic config write: " + inst.Name + " " + key + "=" + formatAny(inst.Values[key]),
|
|
Outcome: audit.OutcomeOK,
|
|
}
|
|
if _, err := e.cfg.UpdateInstance(instanceID, inst, ""); err != nil {
|
|
e.log.Warn("control logic: config write failed", "instance", instanceID, "key", key, "err", err)
|
|
ev.Outcome = audit.OutcomeError
|
|
ev.Error = err.Error()
|
|
}
|
|
e.audit.Record(ev)
|
|
}
|
|
|
|
// createConfigInstance creates a new config instance for setID, optionally
|
|
// copying values from an existing instance (fromID). A missing store/set is a
|
|
// no-op (logged). Audited. The new instance's id is logged but not written back
|
|
// to a flow variable (control-logic variables are numeric, ids are strings).
|
|
func (e *Engine) createConfigInstance(cg *compiledGraph, setID, name, fromID string) {
|
|
if e.cfg == nil || setID == "" {
|
|
return
|
|
}
|
|
if name == "" {
|
|
name = "auto"
|
|
}
|
|
values := map[string]any{}
|
|
if fromID != "" {
|
|
if src, err := e.cfg.GetInstance(fromID); err == nil {
|
|
for k, v := range src.Values {
|
|
values[k] = v
|
|
}
|
|
} else {
|
|
e.log.Warn("control logic: config create: copy source missing", "from", fromID, "err", err)
|
|
}
|
|
}
|
|
inst := confmgr.ConfigInstance{Name: name, SetID: setID, Values: values}
|
|
ev := audit.Event{
|
|
Actor: cg.name,
|
|
ActorType: audit.ActorSystem,
|
|
Action: "config.instance.create",
|
|
Detail: "control logic config create: set=" + setID + " name=" + name,
|
|
Outcome: audit.OutcomeOK,
|
|
}
|
|
out, err := e.cfg.CreateInstance(inst, "")
|
|
if err != nil {
|
|
e.log.Warn("control logic: config create failed", "set", setID, "err", err)
|
|
ev.Outcome = audit.OutcomeError
|
|
ev.Error = err.Error()
|
|
} else {
|
|
ev.Detail += " -> " + out.ID
|
|
}
|
|
e.audit.Record(ev)
|
|
}
|
|
|
|
// snapshotConfig captures the current value of every target signal of a set and
|
|
// stores them as a new config instance. A missing store/set is a no-op (logged).
|
|
// Audited. The new instance's id is logged but not written back to a flow
|
|
// variable (control-logic variables are numeric, ids are strings).
|
|
func (e *Engine) snapshotConfig(cg *compiledGraph, setID, name string) {
|
|
if e.cfg == nil || setID == "" {
|
|
return
|
|
}
|
|
set, err := e.cfg.GetSet(setID)
|
|
if err != nil {
|
|
e.log.Warn("control logic: config snapshot: unknown set", "set", setID, "err", err)
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(e.root, 5*time.Second)
|
|
defer cancel()
|
|
read := func(ds, signal string) (any, error) {
|
|
if ds == "local" {
|
|
return cg.getLocal(signal), nil
|
|
}
|
|
v, err := e.broker.ReadNow(ctx, broker.SignalRef{DS: ds, Name: signal})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return v.Data, nil
|
|
}
|
|
snap := confmgr.Snapshot(set, read)
|
|
if name == "" {
|
|
name = set.Name + " snapshot"
|
|
}
|
|
inst := confmgr.ConfigInstance{Name: name, SetID: setID, Values: snap.Values}
|
|
ev := audit.Event{
|
|
Actor: cg.name,
|
|
ActorType: audit.ActorSystem,
|
|
Action: "config.instance.snapshot",
|
|
Detail: "control logic config snapshot: set=" + setID + " name=" + name,
|
|
Outcome: audit.OutcomeOK,
|
|
}
|
|
out, err := e.cfg.CreateInstance(inst, "")
|
|
if err != nil {
|
|
e.log.Warn("control logic: config snapshot failed", "set", setID, "err", err)
|
|
ev.Outcome = audit.OutcomeError
|
|
ev.Error = err.Error()
|
|
} else {
|
|
ev.Detail += " -> " + out.ID + " captured=" + strconv.Itoa(snap.Captured) + " failed=" + strconv.Itoa(snap.Failed)
|
|
}
|
|
e.audit.Record(ev)
|
|
}
|
|
|
|
// coerceParamValue converts a numeric flow value to the Go type a config
|
|
// parameter expects, so the resulting instance validates against its set.
|
|
func coerceParamValue(p confmgr.Parameter, val float64) any {
|
|
switch p.Type {
|
|
case confmgr.TypeInt:
|
|
return int64(val)
|
|
case confmgr.TypeBool:
|
|
return val != 0
|
|
case confmgr.TypeString, confmgr.TypeEnum:
|
|
return strconv.FormatFloat(val, 'g', -1, 64)
|
|
default:
|
|
return val
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
to string
|
|
port string
|
|
}
|
|
|
|
// compiledGraph holds the runtime state for one enabled graph.
|
|
type compiledGraph struct {
|
|
engine *Engine
|
|
genCtx context.Context
|
|
wg *sync.WaitGroup
|
|
|
|
// dryRun suppresses real side effects (data-source writes, config mutations,
|
|
// dialogs) — used by the debug "simulate" sandbox. Local-variable writes stay
|
|
// live so the flow still computes correctly. alwaysDebug forces emitDebug to
|
|
// fire regardless of debugWatch (the sandbox is its own subscriber).
|
|
dryRun bool
|
|
alwaysDebug bool
|
|
|
|
id string
|
|
name string
|
|
byId map[string]Node
|
|
out map[string][]wireOut
|
|
inc map[string][]string // incoming source ids per node (for gates)
|
|
refs map[string]RefLite // unique signals to subscribe (excl. sys/local)
|
|
|
|
watchers map[string][]string // signal key → trigger node ids
|
|
luaNodes map[string]*luaRuntime
|
|
|
|
// fireCh receives node ids of triggers the debug UI wants to fire manually.
|
|
// Drained by a generation goroutine (started in startTriggers) so the wg.Add
|
|
// in activate always happens on a tracked goroutine.
|
|
fireCh chan string
|
|
|
|
stateMu sync.Mutex
|
|
levelState map[string]bool // current truth of level triggers (threshold/alarm)
|
|
prevBool map[string]bool // edge detection for threshold/alarm
|
|
prevVal map[string]float64 // last value for change triggers
|
|
hasVal map[string]bool
|
|
lastFire map[string]int64 // ns wall clock each trigger last fired
|
|
locals map[string]float64
|
|
}
|
|
|
|
func compile(g Graph) *compiledGraph {
|
|
cg := &compiledGraph{
|
|
id: g.ID,
|
|
name: g.Name,
|
|
byId: map[string]Node{},
|
|
out: map[string][]wireOut{},
|
|
inc: map[string][]string{},
|
|
refs: map[string]RefLite{},
|
|
watchers: map[string][]string{},
|
|
luaNodes: map[string]*luaRuntime{},
|
|
levelState: map[string]bool{},
|
|
prevBool: map[string]bool{},
|
|
prevVal: map[string]float64{},
|
|
hasVal: map[string]bool{},
|
|
lastFire: map[string]int64{},
|
|
locals: map[string]float64{},
|
|
fireCh: make(chan string, 16),
|
|
}
|
|
for _, n := range g.Nodes {
|
|
cg.byId[n.ID] = n
|
|
}
|
|
for _, w := range g.Wires {
|
|
port := w.FromPort
|
|
if port == "" {
|
|
port = "out"
|
|
}
|
|
cg.out[w.From] = append(cg.out[w.From], wireOut{to: w.To, port: port})
|
|
cg.inc[w.To] = append(cg.inc[w.To], w.From)
|
|
}
|
|
|
|
want := func(ds, name string) {
|
|
if ds == "sys" || ds == "local" {
|
|
return
|
|
}
|
|
if name == "" {
|
|
return
|
|
}
|
|
cg.refs[refKey(ds, name)] = RefLite{DS: ds, Name: name}
|
|
}
|
|
wantExpr := func(expr string) {
|
|
for _, r := range CollectRefs(expr) {
|
|
want(r.DS, r.Name)
|
|
}
|
|
}
|
|
|
|
for _, n := range g.Nodes {
|
|
switch n.Kind {
|
|
case "trigger.threshold", "trigger.change", "trigger.alarm":
|
|
if ds, name, ok := parseRef(n.param("signal")); ok {
|
|
want(ds, name)
|
|
key := refKey(ds, name)
|
|
cg.watchers[key] = append(cg.watchers[key], n.ID)
|
|
}
|
|
case "flow.if":
|
|
wantExpr(n.param("cond"))
|
|
case "flow.loop":
|
|
if n.param("mode") == "while" {
|
|
wantExpr(n.param("cond"))
|
|
}
|
|
case "action.write", "action.log":
|
|
wantExpr(n.param("expr"))
|
|
case "action.lua":
|
|
cg.luaNodes[n.ID] = newLuaRuntime(n.param("script"))
|
|
for _, r := range luaGetRefs(n.param("script")) {
|
|
want(r.DS, r.Name)
|
|
}
|
|
}
|
|
}
|
|
return cg
|
|
}
|
|
|
|
func (cg *compiledGraph) setLocal(name string, v float64) {
|
|
cg.stateMu.Lock()
|
|
cg.locals[name] = v
|
|
cg.stateMu.Unlock()
|
|
}
|
|
|
|
func (cg *compiledGraph) getLocal(name string) float64 {
|
|
cg.stateMu.Lock()
|
|
defer cg.stateMu.Unlock()
|
|
v, ok := cg.locals[name]
|
|
if !ok {
|
|
return 0
|
|
}
|
|
return v
|
|
}
|
|
|
|
// startTriggers launches timer and cron trigger goroutines for the generation.
|
|
func (cg *compiledGraph) startTriggers() {
|
|
// Manual-fire listener: drain fireCh on a tracked goroutine so activate's
|
|
// wg.Add never races the generation teardown's wg.Wait. Only fires nodes that
|
|
// are actual triggers in this graph.
|
|
cg.wg.Add(1)
|
|
go func() {
|
|
defer cg.wg.Done()
|
|
for {
|
|
select {
|
|
case id := <-cg.fireCh:
|
|
if n, ok := cg.byId[id]; ok && strings.HasPrefix(n.Kind, "trigger.") {
|
|
cg.activate(id)
|
|
}
|
|
case <-cg.genCtx.Done():
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
hasCron := false
|
|
for _, n := range cg.byId {
|
|
switch n.Kind {
|
|
case "trigger.timer":
|
|
node := n
|
|
cg.wg.Add(1)
|
|
go func() {
|
|
defer cg.wg.Done()
|
|
d := intervalOf(node)
|
|
t := time.NewTicker(d)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-t.C:
|
|
cg.activate(node.ID)
|
|
case <-cg.genCtx.Done():
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
case "trigger.cron":
|
|
hasCron = true
|
|
}
|
|
}
|
|
if hasCron {
|
|
cg.startCron()
|
|
}
|
|
}
|
|
|
|
func (cg *compiledGraph) startCron() {
|
|
type cronNode struct {
|
|
id string
|
|
sched *Schedule
|
|
}
|
|
var crons []cronNode
|
|
for _, n := range cg.byId {
|
|
if n.Kind != "trigger.cron" {
|
|
continue
|
|
}
|
|
sched, err := ParseSchedule(n.param("spec"))
|
|
if err != nil {
|
|
cg.engine.log.Warn("control logic: bad cron spec", "graph", cg.name, "spec", n.param("spec"), "err", err)
|
|
continue
|
|
}
|
|
crons = append(crons, cronNode{id: n.ID, sched: sched})
|
|
}
|
|
if len(crons) == 0 {
|
|
return
|
|
}
|
|
cg.wg.Add(1)
|
|
go func() {
|
|
defer cg.wg.Done()
|
|
t := time.NewTicker(time.Second)
|
|
defer t.Stop()
|
|
lastMinute := -1
|
|
for {
|
|
select {
|
|
case now := <-t.C:
|
|
minute := now.Hour()*60 + now.Minute()
|
|
if minute == lastMinute {
|
|
continue // fire at most once per minute
|
|
}
|
|
lastMinute = minute
|
|
for _, c := range crons {
|
|
if c.sched.Match(now) {
|
|
cg.activate(c.id)
|
|
}
|
|
}
|
|
case <-cg.genCtx.Done():
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
func intervalOf(n Node) time.Duration {
|
|
ms, err := strconv.Atoi(strings.TrimSpace(n.param("interval")))
|
|
if err != nil || ms < 50 {
|
|
ms = 1000
|
|
}
|
|
return time.Duration(ms) * time.Millisecond
|
|
}
|
|
|
|
// ── trigger evaluation ─────────────────────────────────────────────────────────
|
|
|
|
// onSignal drives threshold/alarm (rising edge) and change triggers when a
|
|
// watched signal updates.
|
|
func (cg *compiledGraph) onSignal(key string, value float64) {
|
|
for _, id := range cg.watchers[key] {
|
|
node, ok := cg.byId[id]
|
|
if !ok {
|
|
continue
|
|
}
|
|
switch node.Kind {
|
|
case "trigger.threshold":
|
|
cur := testThreshold(value, node.param("op"), parseFloat(node.param("value")))
|
|
cg.stateMu.Lock()
|
|
cg.levelState[id] = cur
|
|
prev := cg.prevBool[id]
|
|
cg.prevBool[id] = cur
|
|
cg.stateMu.Unlock()
|
|
if cur && !prev {
|
|
cg.activate(id)
|
|
}
|
|
case "trigger.alarm":
|
|
lo := parseFloat(node.param("min"))
|
|
hi := parseFloat(node.param("max"))
|
|
cur := !math.IsNaN(value) && (value < lo || value > hi)
|
|
cg.stateMu.Lock()
|
|
cg.levelState[id] = cur
|
|
prev := cg.prevBool[id]
|
|
cg.prevBool[id] = cur
|
|
cg.stateMu.Unlock()
|
|
if cur && !prev {
|
|
cg.activate(id)
|
|
}
|
|
case "trigger.change":
|
|
cg.stateMu.Lock()
|
|
had := cg.hasVal[id]
|
|
prev := cg.prevVal[id]
|
|
cg.prevVal[id] = value
|
|
cg.hasVal[id] = true
|
|
cg.stateMu.Unlock()
|
|
if had && value != prev {
|
|
cg.activate(id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func testThreshold(val float64, op string, cmp float64) bool {
|
|
if math.IsNaN(val) {
|
|
return false
|
|
}
|
|
switch op {
|
|
case "<":
|
|
return val < cmp
|
|
case ">=":
|
|
return val >= cmp
|
|
case "<=":
|
|
return val <= cmp
|
|
case "==":
|
|
return val == cmp
|
|
case "!=":
|
|
return val != cmp
|
|
default: // ">"
|
|
return val > cmp
|
|
}
|
|
}
|
|
|
|
func parseFloat(s string) float64 {
|
|
f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return f
|
|
}
|
|
|
|
// ── execution ──────────────────────────────────────────────────────────────────
|
|
|
|
type runCtx struct {
|
|
fired string
|
|
steps int
|
|
resolve Resolver
|
|
}
|
|
|
|
// activate spawns a flow run for a trigger on its own goroutine so that
|
|
// action.delay does not block signal dispatch or other flows.
|
|
func (cg *compiledGraph) activate(triggerID string) {
|
|
now := time.Now().UnixNano()
|
|
cg.stateMu.Lock()
|
|
last, had := cg.lastFire[triggerID]
|
|
cg.lastFire[triggerID] = now
|
|
cg.stateMu.Unlock()
|
|
dt := 0.0
|
|
if had {
|
|
dt = float64(now-last) / 1e9
|
|
}
|
|
|
|
resolve := func(ds, name string) Value { // shim: was float64 (Task 4 removes)
|
|
switch ds {
|
|
case "sys":
|
|
if name == "dt" {
|
|
return dt
|
|
}
|
|
return cg.engine.liveGet("sys", name)
|
|
case "local":
|
|
return cg.getLocal(name)
|
|
default:
|
|
return cg.engine.liveGet(ds, name)
|
|
}
|
|
}
|
|
|
|
cg.emitDebug(triggerID, 0, false)
|
|
|
|
cg.wg.Add(1)
|
|
go func() {
|
|
defer cg.wg.Done()
|
|
ctx := &runCtx{fired: triggerID, resolve: resolve}
|
|
cg.follow(triggerID, "out", ctx)
|
|
}()
|
|
}
|
|
|
|
func (cg *compiledGraph) follow(fromID, port string, ctx *runCtx) {
|
|
for _, w := range cg.out[fromID] {
|
|
if w.port == port {
|
|
cg.run(w.to, ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
|
|
if ctx.steps > maxSteps {
|
|
return
|
|
}
|
|
ctx.steps++
|
|
node, ok := cg.byId[nodeID]
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
select {
|
|
case <-cg.genCtx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
cg.emitDebug(node.ID, 0, false)
|
|
|
|
switch node.Kind {
|
|
case "gate.and":
|
|
if cg.gateSatisfied(node.ID, ctx.fired) {
|
|
cg.follow(node.ID, "out", ctx)
|
|
}
|
|
|
|
case "flow.if":
|
|
branch := "else"
|
|
pass := EvalBool(node.param("cond"), ctx.resolve)
|
|
if pass {
|
|
branch = "then"
|
|
}
|
|
v := 0.0
|
|
if pass {
|
|
v = 1
|
|
}
|
|
cg.emitDebug(node.ID, v, true)
|
|
cg.follow(node.ID, branch, ctx)
|
|
|
|
case "flow.loop":
|
|
if node.param("mode") == "while" {
|
|
for i := 0; i < maxLoop && ctx.steps <= maxSteps && EvalBool(node.param("cond"), ctx.resolve); i++ {
|
|
cg.follow(node.ID, "body", ctx)
|
|
}
|
|
} else {
|
|
n := int(EvalExpr(node.param("count"), ctx.resolve))
|
|
if n < 0 {
|
|
n = 0
|
|
}
|
|
if n > maxLoop {
|
|
n = maxLoop
|
|
}
|
|
for i := 0; i < n && ctx.steps <= maxSteps; i++ {
|
|
cg.follow(node.ID, "body", ctx)
|
|
}
|
|
}
|
|
cg.follow(node.ID, "done", ctx)
|
|
|
|
case "action.write":
|
|
val := EvalExpr(node.param("expr"), ctx.resolve)
|
|
cg.emitDebug(node.ID, val, true)
|
|
cg.engine.write(cg, node.param("target"), val)
|
|
cg.follow(node.ID, "out", ctx)
|
|
|
|
case "action.config.apply":
|
|
if !cg.dryRun {
|
|
cg.engine.applyConfig(cg, strings.TrimSpace(node.param("instance")))
|
|
}
|
|
cg.follow(node.ID, "out", ctx)
|
|
|
|
case "action.config.read":
|
|
v, ok := cg.engine.readConfigParam(strings.TrimSpace(node.param("instance")), strings.TrimSpace(node.param("key")))
|
|
if ok {
|
|
cg.engine.write(cg, node.param("target"), v)
|
|
}
|
|
cg.follow(node.ID, "out", ctx)
|
|
|
|
case "action.config.write":
|
|
val := EvalExpr(node.param("expr"), ctx.resolve)
|
|
cg.emitDebug(node.ID, val, true)
|
|
if !cg.dryRun {
|
|
cg.engine.writeConfigParam(cg, strings.TrimSpace(node.param("instance")), strings.TrimSpace(node.param("key")), val)
|
|
}
|
|
cg.follow(node.ID, "out", ctx)
|
|
|
|
case "action.config.create":
|
|
if !cg.dryRun {
|
|
cg.engine.createConfigInstance(cg, strings.TrimSpace(node.param("set")), strings.TrimSpace(node.param("name")), strings.TrimSpace(node.param("from")))
|
|
}
|
|
cg.follow(node.ID, "out", ctx)
|
|
|
|
case "action.config.snapshot":
|
|
if !cg.dryRun {
|
|
cg.engine.snapshotConfig(cg, strings.TrimSpace(node.param("set")), strings.TrimSpace(node.param("name")))
|
|
}
|
|
cg.follow(node.ID, "out", ctx)
|
|
|
|
case "action.delay":
|
|
ms := 0
|
|
if v, err := strconv.Atoi(strings.TrimSpace(node.param("ms"))); err == nil && v > 0 {
|
|
ms = v
|
|
}
|
|
if ms > 0 {
|
|
t := time.NewTimer(time.Duration(ms) * time.Millisecond)
|
|
select {
|
|
case <-t.C:
|
|
case <-cg.genCtx.Done():
|
|
t.Stop()
|
|
return
|
|
}
|
|
}
|
|
cg.follow(node.ID, "out", ctx)
|
|
|
|
case "action.log":
|
|
val := EvalExpr(node.param("expr"), ctx.resolve)
|
|
cg.emitDebug(node.ID, val, true)
|
|
label := strings.TrimSpace(node.param("label"))
|
|
cg.engine.log.Info("control logic log", "graph", cg.name, "label", label, "value", val)
|
|
cg.follow(node.ID, "out", ctx)
|
|
|
|
case "action.lua":
|
|
cg.runLua(node.ID, ctx)
|
|
cg.follow(node.ID, "out", ctx)
|
|
|
|
case "action.dialog":
|
|
if !cg.dryRun {
|
|
cg.engine.emitDialog(node)
|
|
}
|
|
cg.follow(node.ID, "out", ctx)
|
|
|
|
default:
|
|
cg.follow(node.ID, "out", ctx)
|
|
}
|
|
}
|
|
|
|
// gateSatisfied: every incoming trigger must currently be satisfied. The firing
|
|
// trigger counts as satisfied; level triggers (threshold/alarm) use their truth.
|
|
func (cg *compiledGraph) gateSatisfied(gateID, fired string) bool {
|
|
inputs := cg.inc[gateID]
|
|
if len(inputs) == 0 {
|
|
return false
|
|
}
|
|
cg.stateMu.Lock()
|
|
defer cg.stateMu.Unlock()
|
|
for _, src := range inputs {
|
|
if src == fired || cg.levelState[src] {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// luaGetRefs scans a Lua script for get("ds:name") / get('ds:name') literals so
|
|
// the engine can subscribe to the signals the script reads.
|
|
var luaGetRe = regexp.MustCompile(`get\s*\(\s*["']([^"']+)["']`)
|
|
|
|
func luaGetRefs(script string) []RefLite {
|
|
var out []RefLite
|
|
for _, m := range luaGetRe.FindAllStringSubmatch(script, -1) {
|
|
if ds, name, ok := parseRef(m[1]); ok {
|
|
out = append(out, RefLite{DS: ds, Name: name})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (cg *compiledGraph) runLua(nodeID string, ctx *runCtx) {
|
|
lr := cg.luaNodes[nodeID]
|
|
if lr == nil {
|
|
return
|
|
}
|
|
lr.run(ctx.resolve, func(target string, val float64) {
|
|
cg.engine.write(cg, target, val)
|
|
}, func(msg string) {
|
|
cg.engine.log.Info("control logic lua", "graph", cg.name, "msg", msg)
|
|
})
|
|
}
|