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,527 @@
|
||||
// Small, safe expression evaluator — a Go port of web/src/lib/expr.ts.
|
||||
//
|
||||
// Supports numbers, booleans (true/false → 1/0), arithmetic (+ - * / %),
|
||||
// comparison (< <= > >= == !=), boolean (&& || !), ternary (a ? b : c),
|
||||
// parentheses, and a handful of math functions. Two kinds of variable
|
||||
// reference are resolved live at evaluation time:
|
||||
//
|
||||
// {ds:name} a data-source signal value (the brace content is split on the
|
||||
// FIRST ':' so EPICS PV names like "MY:PV:NAME" work).
|
||||
// bareIdent a graph-local state variable (data source "local").
|
||||
//
|
||||
// Booleans are represented as numbers: comparisons / logical ops yield 1 or 0,
|
||||
// and any nonzero value is truthy. The evaluator never uses reflection or eval;
|
||||
// it walks a parsed AST against a caller-supplied Resolver.
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Resolver returns the current numeric value of a signal/local reference.
|
||||
type Resolver func(ds, name string) float64
|
||||
|
||||
// RefLite identifies one signal/local reference read by an expression.
|
||||
type RefLite struct {
|
||||
DS string
|
||||
Name string
|
||||
}
|
||||
|
||||
// ── AST ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
type exprNode interface{ eval(R Resolver) float64 }
|
||||
|
||||
type numNode struct{ v float64 }
|
||||
type sigNode struct{ ds, name string }
|
||||
type varNode struct{ name string }
|
||||
type unNode struct {
|
||||
op string
|
||||
a exprNode
|
||||
}
|
||||
type binNode struct {
|
||||
op string
|
||||
a, b exprNode
|
||||
}
|
||||
type ternNode struct{ c, a, b exprNode }
|
||||
type callNode struct {
|
||||
fn string
|
||||
args []exprNode
|
||||
}
|
||||
|
||||
func (n numNode) eval(R Resolver) float64 { return n.v }
|
||||
func (n sigNode) eval(R Resolver) float64 { return R(n.ds, n.name) }
|
||||
func (n varNode) eval(R Resolver) float64 { return R("local", n.name) }
|
||||
func (n unNode) eval(R Resolver) float64 {
|
||||
if n.op == "-" {
|
||||
return -n.a.eval(R)
|
||||
}
|
||||
if n.a.eval(R) == 0 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
func (n ternNode) eval(R Resolver) float64 {
|
||||
if n.c.eval(R) != 0 {
|
||||
return n.a.eval(R)
|
||||
}
|
||||
return n.b.eval(R)
|
||||
}
|
||||
func (n callNode) eval(R Resolver) float64 {
|
||||
args := make([]float64, len(n.args))
|
||||
for i, a := range n.args {
|
||||
args[i] = a.eval(R)
|
||||
}
|
||||
return funcs[n.fn](args)
|
||||
}
|
||||
func (n binNode) eval(R Resolver) float64 {
|
||||
a, b := n.a.eval(R), n.b.eval(R)
|
||||
switch n.op {
|
||||
case "+":
|
||||
return a + b
|
||||
case "-":
|
||||
return a - b
|
||||
case "*":
|
||||
return a * b
|
||||
case "/":
|
||||
return a / b
|
||||
case "%":
|
||||
return math.Mod(a, b)
|
||||
case "<":
|
||||
return boolf(a < b)
|
||||
case "<=":
|
||||
return boolf(a <= b)
|
||||
case ">":
|
||||
return boolf(a > b)
|
||||
case ">=":
|
||||
return boolf(a >= b)
|
||||
case "==":
|
||||
return boolf(a == b)
|
||||
case "!=":
|
||||
return boolf(a != b)
|
||||
case "&&":
|
||||
return boolf(a != 0 && b != 0)
|
||||
case "||":
|
||||
return boolf(a != 0 || b != 0)
|
||||
}
|
||||
return math.NaN()
|
||||
}
|
||||
|
||||
func boolf(b bool) float64 {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
var funcs = map[string]func([]float64) float64{
|
||||
"abs": func(a []float64) float64 { return math.Abs(a[0]) },
|
||||
"min": func(a []float64) float64 { return minSlice(a) },
|
||||
"max": func(a []float64) float64 { return maxSlice(a) },
|
||||
"sqrt": func(a []float64) float64 { return math.Sqrt(a[0]) },
|
||||
"floor": func(a []float64) float64 { return math.Floor(a[0]) },
|
||||
"ceil": func(a []float64) float64 { return math.Ceil(a[0]) },
|
||||
"round": func(a []float64) float64 { return math.Round(a[0]) },
|
||||
"sign": func(a []float64) float64 { return float64(sign(a[0])) },
|
||||
"pow": func(a []float64) float64 { return math.Pow(a[0], a[1]) },
|
||||
"log": func(a []float64) float64 { return math.Log(a[0]) },
|
||||
"exp": func(a []float64) float64 { return math.Exp(a[0]) },
|
||||
"sin": func(a []float64) float64 { return math.Sin(a[0]) },
|
||||
"cos": func(a []float64) float64 { return math.Cos(a[0]) },
|
||||
}
|
||||
|
||||
func minSlice(a []float64) float64 {
|
||||
if len(a) == 0 {
|
||||
return math.Inf(1)
|
||||
}
|
||||
m := a[0]
|
||||
for _, x := range a[1:] {
|
||||
m = math.Min(m, x)
|
||||
}
|
||||
return m
|
||||
}
|
||||
func maxSlice(a []float64) float64 {
|
||||
if len(a) == 0 {
|
||||
return math.Inf(-1)
|
||||
}
|
||||
m := a[0]
|
||||
for _, x := range a[1:] {
|
||||
m = math.Max(m, x)
|
||||
}
|
||||
return m
|
||||
}
|
||||
func sign(x float64) int {
|
||||
switch {
|
||||
case x > 0:
|
||||
return 1
|
||||
case x < 0:
|
||||
return -1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tokenizer ────────────────────────────────────────────────────────────────
|
||||
|
||||
type tok struct {
|
||||
k string
|
||||
v string
|
||||
}
|
||||
|
||||
func tokenize(src string) ([]tok, error) {
|
||||
var toks []tok
|
||||
two := map[string]bool{"<=": true, ">=": true, "==": true, "!=": true, "&&": true, "||": true}
|
||||
r := []rune(src)
|
||||
i := 0
|
||||
for i < len(r) {
|
||||
c := r[i]
|
||||
switch {
|
||||
case c == ' ' || c == '\t' || c == '\n' || c == '\r':
|
||||
i++
|
||||
continue
|
||||
case c == '{':
|
||||
end := -1
|
||||
for j := i + 1; j < len(r); j++ {
|
||||
if r[j] == '}' {
|
||||
end = j
|
||||
break
|
||||
}
|
||||
}
|
||||
if end < 0 {
|
||||
return nil, fmt.Errorf("unterminated { in expression")
|
||||
}
|
||||
toks = append(toks, tok{k: "sig", v: string(r[i+1 : end])})
|
||||
i = end + 1
|
||||
continue
|
||||
}
|
||||
if isDigit(c) || (c == '.' && i+1 < len(r) && isDigit(r[i+1])) {
|
||||
j := i + 1
|
||||
for j < len(r) && (isDigit(r[j]) || r[j] == '.') {
|
||||
j++
|
||||
}
|
||||
toks = append(toks, tok{k: "num", v: string(r[i:j])})
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
if isIdentStart(c) {
|
||||
j := i + 1
|
||||
for j < len(r) && isIdentPart(r[j]) {
|
||||
j++
|
||||
}
|
||||
toks = append(toks, tok{k: "ident", v: string(r[i:j])})
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
if i+1 < len(r) {
|
||||
pair := string(r[i : i+2])
|
||||
if two[pair] {
|
||||
toks = append(toks, tok{k: pair})
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
if strings.ContainsRune("+-*/%<>!()?:,", c) {
|
||||
toks = append(toks, tok{k: string(c)})
|
||||
i++
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected character %q in expression", string(c))
|
||||
}
|
||||
return toks, nil
|
||||
}
|
||||
|
||||
func isDigit(c rune) bool { return c >= '0' && c <= '9' }
|
||||
func isIdentStart(c rune) bool { return c == '_' || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') }
|
||||
func isIdentPart(c rune) bool { return isIdentStart(c) || isDigit(c) }
|
||||
|
||||
// ── Parser (recursive descent) ────────────────────────────────────────────────
|
||||
|
||||
type parser struct {
|
||||
toks []tok
|
||||
p int
|
||||
}
|
||||
|
||||
func (ps *parser) peek() (tok, bool) {
|
||||
if ps.p < len(ps.toks) {
|
||||
return ps.toks[ps.p], true
|
||||
}
|
||||
return tok{}, false
|
||||
}
|
||||
|
||||
func (ps *parser) eat(k string) (tok, error) {
|
||||
if ps.p >= len(ps.toks) {
|
||||
return tok{}, fmt.Errorf("unexpected end of expression")
|
||||
}
|
||||
t := ps.toks[ps.p]
|
||||
if k != "" && t.k != k {
|
||||
return tok{}, fmt.Errorf("expected %q in expression", k)
|
||||
}
|
||||
ps.p++
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func parse(src string) (exprNode, error) {
|
||||
toks, err := tokenize(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ps := &parser{toks: toks}
|
||||
root, err := ps.ternary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ps.p < len(ps.toks) {
|
||||
return nil, fmt.Errorf("trailing tokens in expression")
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
func (ps *parser) primary() (exprNode, error) {
|
||||
t, ok := ps.peek()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected end of expression")
|
||||
}
|
||||
switch t.k {
|
||||
case "num":
|
||||
ps.eat("")
|
||||
v, err := strconv.ParseFloat(t.v, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bad number %q", t.v)
|
||||
}
|
||||
return numNode{v: v}, nil
|
||||
case "sig":
|
||||
ps.eat("")
|
||||
idx := strings.IndexByte(t.v, ':')
|
||||
if idx < 0 {
|
||||
return sigNode{ds: t.v, name: ""}, nil
|
||||
}
|
||||
return sigNode{ds: t.v[:idx], name: t.v[idx+1:]}, nil
|
||||
case "ident":
|
||||
ps.eat("")
|
||||
id := t.v
|
||||
if id == "true" {
|
||||
return numNode{v: 1}, nil
|
||||
}
|
||||
if id == "false" {
|
||||
return numNode{v: 0}, nil
|
||||
}
|
||||
if nx, ok := ps.peek(); ok && nx.k == "(" {
|
||||
ps.eat("(")
|
||||
var args []exprNode
|
||||
if nx2, ok := ps.peek(); ok && nx2.k != ")" {
|
||||
a, err := ps.ternary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, a)
|
||||
for {
|
||||
nx3, ok := ps.peek()
|
||||
if !ok || nx3.k != "," {
|
||||
break
|
||||
}
|
||||
ps.eat(",")
|
||||
a, err := ps.ternary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, a)
|
||||
}
|
||||
}
|
||||
if _, err := ps.eat(")"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := funcs[id]; !ok {
|
||||
return nil, fmt.Errorf("unknown function %q", id)
|
||||
}
|
||||
return callNode{fn: id, args: args}, nil
|
||||
}
|
||||
return varNode{name: id}, nil
|
||||
case "(":
|
||||
ps.eat("(")
|
||||
e, err := ps.ternary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := ps.eat(")"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected token %q in expression", t.k)
|
||||
}
|
||||
|
||||
func (ps *parser) unary() (exprNode, error) {
|
||||
if t, ok := ps.peek(); ok && (t.k == "-" || t.k == "!") {
|
||||
ps.eat("")
|
||||
a, err := ps.unary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return unNode{op: t.k, a: a}, nil
|
||||
}
|
||||
return ps.primary()
|
||||
}
|
||||
|
||||
func (ps *parser) binLevel(next func() (exprNode, error), ops ...string) (exprNode, error) {
|
||||
a, err := next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for {
|
||||
t, ok := ps.peek()
|
||||
if !ok || !contains(ops, t.k) {
|
||||
return a, nil
|
||||
}
|
||||
op, _ := ps.eat("")
|
||||
b, err := next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a = binNode{op: op.k, a: a, b: b}
|
||||
}
|
||||
}
|
||||
|
||||
func (ps *parser) mul() (exprNode, error) { return ps.binLevel(ps.unary, "*", "/", "%") }
|
||||
func (ps *parser) add() (exprNode, error) { return ps.binLevel(ps.mul, "+", "-") }
|
||||
func (ps *parser) cmp() (exprNode, error) { return ps.binLevel(ps.add, "<", "<=", ">", ">=") }
|
||||
func (ps *parser) eq() (exprNode, error) { return ps.binLevel(ps.cmp, "==", "!=") }
|
||||
func (ps *parser) and() (exprNode, error) { return ps.binLevel(ps.eq, "&&") }
|
||||
func (ps *parser) or() (exprNode, error) { return ps.binLevel(ps.and, "||") }
|
||||
|
||||
func (ps *parser) ternary() (exprNode, error) {
|
||||
c, err := ps.or()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t, ok := ps.peek(); ok && t.k == "?" {
|
||||
ps.eat("?")
|
||||
a, err := ps.ternary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := ps.eat(":"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := ps.ternary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ternNode{c: c, a: a, b: b}, nil
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func contains(s []string, v string) bool {
|
||||
for _, x := range s {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Cache + public API ─────────────────────────────────────────────────────────
|
||||
|
||||
type cacheEntry struct {
|
||||
node exprNode
|
||||
err error
|
||||
}
|
||||
|
||||
var (
|
||||
cacheMu sync.Mutex
|
||||
cache = map[string]cacheEntry{}
|
||||
)
|
||||
|
||||
func parseCached(src string) (exprNode, error) {
|
||||
cacheMu.Lock()
|
||||
e, ok := cache[src]
|
||||
cacheMu.Unlock()
|
||||
if ok {
|
||||
return e.node, e.err
|
||||
}
|
||||
n, err := parse(src)
|
||||
cacheMu.Lock()
|
||||
cache[src] = cacheEntry{node: n, err: err}
|
||||
cacheMu.Unlock()
|
||||
return n, err
|
||||
}
|
||||
|
||||
// EvalExpr evaluates an expression string, returning NaN on parse/eval failure.
|
||||
func EvalExpr(src string, resolve Resolver) float64 {
|
||||
n, err := parseCached(src)
|
||||
if err != nil {
|
||||
return math.NaN()
|
||||
}
|
||||
return safeEval(n, resolve)
|
||||
}
|
||||
|
||||
func safeEval(n exprNode, resolve Resolver) (out float64) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
out = math.NaN()
|
||||
}
|
||||
}()
|
||||
return n.eval(resolve)
|
||||
}
|
||||
|
||||
// EvalBool reports whether the expression evaluates to a nonzero, non-NaN value.
|
||||
func EvalBool(src string, resolve Resolver) bool {
|
||||
v := EvalExpr(src, resolve)
|
||||
return !math.IsNaN(v) && v != 0
|
||||
}
|
||||
|
||||
// CollectRefs returns every signal/local reference an expression reads, for
|
||||
// subscription. Returns nil for an unparseable expression.
|
||||
func CollectRefs(src string) []RefLite {
|
||||
root, err := parseCached(src)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []RefLite
|
||||
seen := map[string]bool{}
|
||||
add := func(ds, name string) {
|
||||
k := ds + "\x00" + name
|
||||
if !seen[k] {
|
||||
seen[k] = true
|
||||
out = append(out, RefLite{DS: ds, Name: name})
|
||||
}
|
||||
}
|
||||
var walk func(n exprNode)
|
||||
walk = func(n exprNode) {
|
||||
switch t := n.(type) {
|
||||
case sigNode:
|
||||
add(t.ds, t.name)
|
||||
case varNode:
|
||||
add("local", t.name)
|
||||
case unNode:
|
||||
walk(t.a)
|
||||
case binNode:
|
||||
walk(t.a)
|
||||
walk(t.b)
|
||||
case ternNode:
|
||||
walk(t.c)
|
||||
walk(t.a)
|
||||
walk(t.b)
|
||||
case callNode:
|
||||
for _, a := range t.args {
|
||||
walk(a)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
return out
|
||||
}
|
||||
|
||||
// CheckExpr validates an expression; returns an error message or "" if it parses.
|
||||
func CheckExpr(src string) string {
|
||||
if strings.TrimSpace(src) == "" {
|
||||
return ""
|
||||
}
|
||||
if _, err := parse(src); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user