Add control logic engine, panel logic dialogs, logic-edit restriction
Introduce server-side control-logic flow graphs (cron/alarm triggers, Lua blocks) with CRUD endpoints, panel-logic lifecycle triggers and user-interaction dialog nodes, and a synthetic node-graph editor. Add an optional logic-editor allowlist (server.logic_editors) gating who may add/edit panel logic and control logic, surfaced via /api/v1/me and enforced in the API; hide logic affordances in the UI accordingly. Update README, example config, and functional/technical specs to cover all current features (plot panels, panel/control logic, local variables, access control) and refresh the in-app manual and contextual help. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,688 @@
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
)
|
||||
|
||||
// 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
|
||||
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
|
||||
|
||||
// Shared live signal cache for the current generation (key "ds\0name").
|
||||
liveMu sync.RWMutex
|
||||
live map[string]float64
|
||||
}
|
||||
|
||||
// NewEngine creates an engine bound to root. Call Reload to start it.
|
||||
func NewEngine(root context.Context, brk *broker.Broker, store *Store, log *slog.Logger) *Engine {
|
||||
return &Engine{
|
||||
broker: brk,
|
||||
store: store,
|
||||
log: log,
|
||||
root: root,
|
||||
live: map[string]float64{},
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
}()
|
||||
|
||||
// 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
|
||||
}
|
||||
src, ok := e.broker.Source(ds)
|
||||
if !ok {
|
||||
e.log.Warn("control logic: write to unknown data source", "ds", ds, "signal", name)
|
||||
return
|
||||
}
|
||||
if err := src.Write(e.root, name, val); err != nil {
|
||||
e.log.Warn("control logic: write failed", "ds", ds, "signal", name, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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
|
||||
|
||||
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
|
||||
|
||||
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{
|
||||
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{},
|
||||
}
|
||||
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() {
|
||||
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) float64 {
|
||||
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.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:
|
||||
}
|
||||
|
||||
switch node.Kind {
|
||||
case "gate.and":
|
||||
if cg.gateSatisfied(node.ID, ctx.fired) {
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
}
|
||||
|
||||
case "flow.if":
|
||||
branch := "else"
|
||||
if EvalBool(node.param("cond"), ctx.resolve) {
|
||||
branch = "then"
|
||||
}
|
||||
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.engine.write(cg, node.param("target"), val)
|
||||
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)
|
||||
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)
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user