Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 336095c052 | |||
| c53a49e540 | |||
| b6bc9dc2f2 | |||
| 3133e50e09 | |||
| 550ec06bc8 | |||
| 9c1f2685a7 | |||
| 088063d9cd | |||
| a6fa4e7c7c |
@@ -94,14 +94,14 @@
|
||||
- [x] Live / debug mode in all three node editors — evaluate the graph online and badge each node's value at human speed (~0.5 s). Shared frontend `web/src/lib/flowDebug.ts` (badge/active classes, `useFlowDebug` poller) + CSS `.flow-node-active`/`.flow-node-badge`/`.flow-wire-active`; a green ◉ toggle in each `.flow-palette-toolbar`. Per-editor backends:
|
||||
- [ ] Syntetic signal editor:
|
||||
- [x] Implement live / debug mode where value are evaluated online and visualized at human speed (e.g. 0.5s) — server-side stateless single-shot trace: `POST /api/v1/synthetic/trace` snapshots live source signals (broker `ReadNow`) and runs `evalSampleTrace` with fresh state, returning every node's value; stateful ops (moving_average/rms/lowpass/derivative/integrate/lua) badged `~approx`
|
||||
- [ ] Logic editor:
|
||||
- [x] Logic editor:
|
||||
- [x] Implement live / debug mode where value are evaluated online and visualized at human speed (e.g. 0.5s) — editor spins up a second client-side `LogicEngine` in new `dryRun` mode (real writes/config/dialogs suppressed) loaded with the edited graph; per-node values polled into the shared badges; the view-mode singleton is untouched
|
||||
- add full suppor to local array values: dynamic, dynamic but capped max, fixed size etc:
|
||||
- array functions should work with new local array
|
||||
- NOTE: **Phase 1 (panel logic, client-side TS) is DONE** — array statevars (dynamic/capped/fixed), value-polymorphic expression engine with indexing + array functions, `action.array.*` mutation nodes (legacy accumulate/export/clear unified + auto-migrated), panel-XML round-trip, editor declaration form, and widget array modes (plot/table/multi-LED). Box stays unchecked until **Phase 2** (server-side `internal/controllogic` Go port, see below) lands.
|
||||
- [ ] Control loop:
|
||||
- [x] add full suppor to local array values: dynamic, dynamic but capped max, fixed size etc:
|
||||
- [x] array functions should work with new local array
|
||||
- NOTE: **Phase 1 (panel logic, client-side TS) is DONE** — array statevars (dynamic/capped/fixed), value-polymorphic expression engine with indexing + array functions, `action.array.*` mutation nodes (legacy accumulate/export/clear unified + auto-migrated), panel-XML round-trip, editor declaration form, and widget array modes (plot/table/multi-LED). **Phase 2 (server-side `internal/controllogic` Go port, see below) has now landed**, so this feature is complete across both engines.
|
||||
- [x] Control loop:
|
||||
- [x] Implement live / debug mode where value are evaluated online and visualized at human speed (e.g. 0.5s) — server pushes per-node events over the WS (`debugSubscribe`/`debugNode`) via a new `DebugObserver` on the engine (lock-free `atomic.Value`, gated by a per-graph watch set) + `internal/server/debughub.go` (drop-on-full fan-out). Two modes share one message shape: **Live** observes the running enabled graph; **Simulate** dry-runs the unsaved edits in a throwaway sandbox (`StartSimulate`, side effects suppressed). Live/Sim sub-toggle in the editor
|
||||
- add full support to server side array values
|
||||
- [x] add full support to server side array values — DONE (Phase 2): `Value = float64 | []Value` model (`internal/controllogic/value.go`), `Graph.StateVars` (number/bool/array with dynamic/capped/fixed sizing) persisted in store JSON, value-polymorphic `expr.go` (array literals, negative-wrap indexing, full array-function table), and `action.array.push|set|remove|pop|clear` nodes. Lua block stays scalar-only (array local → NaN); CSV export remains panel-logic-only
|
||||
- [x] Implement git style versioning for: synthetic variable, panels, control logic:
|
||||
- [x] possibility to fork any version
|
||||
- [x] click to view the version
|
||||
|
||||
+23
-3
@@ -376,9 +376,9 @@ now produces **index-aligned** CSV: each configured array becomes a column (cust
|
||||
labels supported), rows aligned by element index. Array statevars and all array nodes
|
||||
round-trip through the panel XML (`<statevar type="array" elem=… sizing=… capacity=…>`).
|
||||
|
||||
> **Note:** This is Phase 1 (panel logic, client-side TypeScript). The equivalent array
|
||||
> support in the server-side control-logic engine (`internal/controllogic`) is a separate
|
||||
> Phase 2 effort and is not yet implemented.
|
||||
> **Note:** The above is Phase 1 (panel logic, client-side TypeScript). The equivalent array
|
||||
> support in the server-side control-logic engine (`internal/controllogic`) is Phase 2 and has
|
||||
> now landed — see the **Array-valued local variables** subsection of §3.9 below.
|
||||
|
||||
### 3.9 Control Logic Engine
|
||||
|
||||
@@ -389,6 +389,26 @@ and writes results back to signals. Graphs are persisted by a store and managed
|
||||
`/api/v1/controllogic`; each mutation calls `Engine.Reload()` to apply changes live. Graphs
|
||||
can be individually enabled/disabled.
|
||||
|
||||
**Array-valued local variables (Phase 2).** Engine locals now carry a `Value` — the tagged
|
||||
union `Value = float64 | []Value` (`internal/controllogic/value.go`), the Go port of the
|
||||
panel-logic `ArrVal` model, with booleans represented as `1`/`0` at the leaves. A graph may
|
||||
declare `statevars`: each has a `Name`, a `Type` (`number` | `bool` | `array`), an `Initial`
|
||||
expression, and — for arrays — a `Sizing` policy and `Capacity`. The three sizing policies
|
||||
mirror the frontend: **dynamic** (unbounded up to `ARRAY_MAX = 1_000_000`, oldest dropped
|
||||
FIFO past the cap), **capped** (length kept ≤ `Capacity`, oldest dropped FIFO), and **fixed**
|
||||
(exactly `Capacity` elements — truncated or zero-padded). Sizing is enforced on write. State
|
||||
vars are persisted in the graph's store JSON and seeded into locals at compile time.
|
||||
|
||||
The expression evaluator (`internal/controllogic/expr.go`) is value-polymorphic: it supports
|
||||
array literals `[a, b, c]`, indexing `arr[i]` (negative indices wrap from the end), and a
|
||||
table of array functions: `len`, `sum`, `mean`, `slice`, `concat`, `reverse`, `sort`, `scale`,
|
||||
`add`, `sub`, `push`, `set`, `insert`, `remove`, `pop`, `shift`, `indexOf`, `contains`, `fill`
|
||||
(plus `min`/`max`, which accept either scalars or an array). These are pure — they produce new
|
||||
values and never mutate a stored local. Five action nodes write back to a declared array
|
||||
statevar: `action.array.push`, `…set`, `…remove`, `…pop`, and `…clear`. The embedded Lua block
|
||||
remains **scalar-only**: reading an array local from Lua yields `NaN`. CSV export
|
||||
(`action.export`) is panel-logic-only and is **not** available in control logic.
|
||||
|
||||
The engine also holds a `*confmgr.Store` (injected via `NewEngine`) for five config action
|
||||
nodes: `action.config.apply` resolves an instance + its set and runs `confmgr.Apply` with a
|
||||
broker-backed, audited write closure; `action.config.read` resolves a single parameter value
|
||||
|
||||
@@ -224,13 +224,7 @@ func normalizeValue(v any) Value {
|
||||
return 1.0
|
||||
}
|
||||
return 0.0
|
||||
case []interface{}:
|
||||
out := make([]Value, len(t))
|
||||
for i, e := range t {
|
||||
out[i] = normalizeValue(e)
|
||||
}
|
||||
return out
|
||||
case []Value:
|
||||
case []interface{}: // note: []Value == []interface{} since Value = any, so this covers both
|
||||
out := make([]Value, len(t))
|
||||
for i, e := range t {
|
||||
out[i] = normalizeValue(e)
|
||||
|
||||
@@ -103,7 +103,7 @@ func setupConfigEngine(t *testing.T) (*Engine, *writableSource, string) {
|
||||
|
||||
func TestApplyConfigWritesEveryParam(t *testing.T) {
|
||||
e, src, instID := setupConfigEngine(t)
|
||||
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
|
||||
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]Value{}}
|
||||
|
||||
e.applyConfig(cg, instID)
|
||||
|
||||
@@ -117,7 +117,7 @@ func TestApplyConfigWritesEveryParam(t *testing.T) {
|
||||
|
||||
func TestApplyConfigUnknownInstanceNoop(t *testing.T) {
|
||||
e, src, _ := setupConfigEngine(t)
|
||||
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
|
||||
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]Value{}}
|
||||
|
||||
e.applyConfig(cg, "does-not-exist")
|
||||
|
||||
@@ -144,7 +144,7 @@ func TestReadConfigParam(t *testing.T) {
|
||||
|
||||
func TestWriteConfigParam(t *testing.T) {
|
||||
e, _, instID := setupConfigEngine(t)
|
||||
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
|
||||
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]Value{}}
|
||||
|
||||
e.writeConfigParam(cg, instID, "gain", 7.5)
|
||||
|
||||
@@ -163,7 +163,7 @@ func TestWriteConfigParam(t *testing.T) {
|
||||
|
||||
func TestCreateConfigInstance(t *testing.T) {
|
||||
e, _, srcID := setupConfigEngine(t)
|
||||
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
|
||||
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]Value{}}
|
||||
|
||||
src, err := e.cfg.GetInstance(srcID)
|
||||
if err != nil {
|
||||
@@ -197,7 +197,7 @@ func TestCreateConfigInstance(t *testing.T) {
|
||||
|
||||
func TestSnapshotConfig(t *testing.T) {
|
||||
e, src, instID := setupConfigEngine(t)
|
||||
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
|
||||
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]Value{}}
|
||||
|
||||
// Seed current live values for the set's target signals.
|
||||
_ = src.Write(context.Background(), "GAIN", 3.5)
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
type DebugEvent struct {
|
||||
GraphID string `json:"graphId"`
|
||||
NodeID string `json:"nodeId"`
|
||||
Value float64 `json:"value"`
|
||||
Value any `json:"value"`
|
||||
HasValue bool `json:"hasValue"`
|
||||
TS int64 `json:"ts"` // unix millis
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func (e *Engine) FireTrigger(graphID, triggerID string) bool {
|
||||
|
||||
// emitDebug reports a node execution to the observer when the graph is watched
|
||||
// (or the graph is a simulate sandbox, which is always its own subscriber).
|
||||
func (cg *compiledGraph) emitDebug(nodeID string, value float64, hasValue bool) {
|
||||
func (cg *compiledGraph) emitDebug(nodeID string, value Value, hasValue bool) {
|
||||
if !cg.alwaysDebug {
|
||||
w, _ := cg.engine.debugWatch.Load().(map[string]bool)
|
||||
if !w[cg.id] {
|
||||
|
||||
@@ -79,7 +79,7 @@ func TestDebugObserverCapturesNodeValues(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("no event for write node 'w'")
|
||||
}
|
||||
if !wEv.HasValue || wEv.Value != 42 {
|
||||
if !wEv.HasValue || wEv.Value != 42.0 {
|
||||
t.Errorf("write node event = %+v, want value 42 hasValue true", wEv)
|
||||
}
|
||||
if wEv.GraphID != "g1" {
|
||||
@@ -136,7 +136,7 @@ func TestFireTriggerForcesRun(t *testing.T) {
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if !wEv.HasValue || wEv.Value != 42 {
|
||||
if !wEv.HasValue || wEv.Value != 42.0 {
|
||||
t.Errorf("write node event = %+v, want value 42 hasValue true", wEv)
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ func TestSimulateSuppressesWrites(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("simulate produced no event for write node 'w'")
|
||||
}
|
||||
if !wEv.HasValue || wEv.Value != 42 {
|
||||
if !wEv.HasValue || wEv.Value != 42.0 {
|
||||
t.Errorf("simulate write event = %+v, want value 42 hasValue true", wEv)
|
||||
}
|
||||
if wEv.GraphID != "sim" {
|
||||
|
||||
+150
-11
@@ -276,17 +276,51 @@ func (e *Engine) liveGet(ds, name string) float64 {
|
||||
return v
|
||||
}
|
||||
|
||||
// setPath assigns v at the nested index path within arr, growing (zero-filling)
|
||||
// as needed and resolving negative indices against length. Adapted from logic.ts;
|
||||
// unlike the single-threaded TS source it copies on descent rather than mutating in
|
||||
// place, because a graph-local slice (and its nested sub-slices) may be read
|
||||
// concurrently by other flow goroutines — in-place mutation of shared backing would
|
||||
// race. Every level returns a freshly allocated slice.
|
||||
func setPath(arr []Value, path []int, v Value) []Value {
|
||||
if len(path) == 0 {
|
||||
return arr
|
||||
}
|
||||
k := path[0]
|
||||
if k < 0 {
|
||||
k = len(arr) + k
|
||||
}
|
||||
if k < 0 {
|
||||
return arr
|
||||
}
|
||||
out := append([]Value{}, arr...) // copy: backing may be shared with the live local
|
||||
for len(out) <= k {
|
||||
out = append(out, 0.0)
|
||||
}
|
||||
if len(path) == 1 {
|
||||
out[k] = v
|
||||
return out
|
||||
}
|
||||
sub, _ := out[k].([]Value)
|
||||
out[k] = setPath(sub, path[1:], v)
|
||||
return out
|
||||
}
|
||||
|
||||
// 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) {
|
||||
func (e *Engine) write(cg *compiledGraph, target string, val Value) {
|
||||
ds, name, ok := parseRef(target)
|
||||
if !ok || math.IsNaN(val) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if ds == "local" {
|
||||
cg.setLocal(name, val)
|
||||
return
|
||||
}
|
||||
f, isNum := val.(float64)
|
||||
if !isNum || math.IsNaN(f) {
|
||||
return
|
||||
}
|
||||
if cg.dryRun {
|
||||
return // simulate: no real data-source write
|
||||
}
|
||||
@@ -301,11 +335,11 @@ func (e *Engine) write(cg *compiledGraph, target string, val float64) {
|
||||
Action: "signal.write",
|
||||
DS: ds,
|
||||
Signal: name,
|
||||
Value: strconv.FormatFloat(val, 'g', -1, 64),
|
||||
Value: strconv.FormatFloat(f, 'g', -1, 64),
|
||||
Detail: "control logic: " + cg.name,
|
||||
Outcome: audit.OutcomeOK,
|
||||
}
|
||||
if err := src.Write(e.root, name, val); err != nil {
|
||||
if err := src.Write(e.root, name, f); err != nil {
|
||||
e.log.Warn("control logic: write failed", "ds", ds, "signal", name, "err", err)
|
||||
ev.Outcome = audit.OutcomeError
|
||||
ev.Error = err.Error()
|
||||
@@ -608,7 +642,8 @@ type compiledGraph struct {
|
||||
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
|
||||
locals map[string]Value
|
||||
decls map[string]StateVar
|
||||
}
|
||||
|
||||
func compile(g Graph) *compiledGraph {
|
||||
@@ -626,12 +661,21 @@ func compile(g Graph) *compiledGraph {
|
||||
prevVal: map[string]float64{},
|
||||
hasVal: map[string]bool{},
|
||||
lastFire: map[string]int64{},
|
||||
locals: map[string]float64{},
|
||||
locals: map[string]Value{},
|
||||
decls: map[string]StateVar{},
|
||||
fireCh: make(chan string, 16),
|
||||
}
|
||||
for _, n := range g.Nodes {
|
||||
cg.byId[n.ID] = n
|
||||
}
|
||||
for _, sv := range g.StateVars {
|
||||
cg.decls[sv.Name] = sv
|
||||
if sv.Type == "array" {
|
||||
cg.locals[sv.Name] = applySizing(parseInitialArray(sv), sv)
|
||||
} else {
|
||||
cg.locals[sv.Name] = parseScalarInitial(sv)
|
||||
}
|
||||
}
|
||||
for _, w := range g.Wires {
|
||||
port := w.FromPort
|
||||
if port == "" {
|
||||
@@ -672,6 +716,13 @@ func compile(g Graph) *compiledGraph {
|
||||
}
|
||||
case "action.write", "action.log":
|
||||
wantExpr(n.param("expr"))
|
||||
case "action.array.push":
|
||||
wantExpr(n.param("expr"))
|
||||
case "action.array.set":
|
||||
wantExpr(n.param("expr"))
|
||||
wantExpr(n.param("index"))
|
||||
case "action.array.remove":
|
||||
wantExpr(n.param("index"))
|
||||
case "action.lua":
|
||||
cg.luaNodes[n.ID] = newLuaRuntime(n.param("script"))
|
||||
for _, r := range luaGetRefs(n.param("script")) {
|
||||
@@ -682,18 +733,38 @@ func compile(g Graph) *compiledGraph {
|
||||
return cg
|
||||
}
|
||||
|
||||
func (cg *compiledGraph) setLocal(name string, v float64) {
|
||||
func parseScalarInitial(sv StateVar) float64 {
|
||||
s := strings.TrimSpace(sv.Initial)
|
||||
switch s {
|
||||
case "true":
|
||||
return 1
|
||||
case "false":
|
||||
return 0
|
||||
}
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (cg *compiledGraph) setLocal(name string, v Value) {
|
||||
cg.stateMu.Lock()
|
||||
if sv, ok := cg.decls[name]; ok && sv.Type == "array" {
|
||||
if arr, isArr := v.([]Value); isArr {
|
||||
v = applySizing(arr, sv)
|
||||
}
|
||||
}
|
||||
cg.locals[name] = v
|
||||
cg.stateMu.Unlock()
|
||||
}
|
||||
|
||||
func (cg *compiledGraph) getLocal(name string) float64 {
|
||||
func (cg *compiledGraph) getLocal(name string) Value {
|
||||
cg.stateMu.Lock()
|
||||
defer cg.stateMu.Unlock()
|
||||
v, ok := cg.locals[name]
|
||||
if !ok {
|
||||
return 0
|
||||
return 0.0
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -897,7 +968,7 @@ func (cg *compiledGraph) activate(triggerID string) {
|
||||
dt = float64(now-last) / 1e9
|
||||
}
|
||||
|
||||
resolve := func(ds, name string) Value { // shim: was float64 (Task 4 removes)
|
||||
resolve := func(ds, name string) Value {
|
||||
switch ds {
|
||||
case "sys":
|
||||
if name == "dt" {
|
||||
@@ -986,7 +1057,7 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
|
||||
cg.follow(node.ID, "done", ctx)
|
||||
|
||||
case "action.write":
|
||||
val := EvalExpr(node.param("expr"), ctx.resolve)
|
||||
val := EvalValue(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)
|
||||
@@ -1057,6 +1128,74 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
|
||||
}
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.array.push":
|
||||
name := strings.TrimSpace(node.param("array"))
|
||||
if name != "" {
|
||||
val := EvalValue(node.param("expr"), ctx.resolve)
|
||||
cur, _ := cg.getLocal(name).([]Value)
|
||||
cg.setLocal(name, append(append([]Value{}, cur...), val))
|
||||
}
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.array.set":
|
||||
name := strings.TrimSpace(node.param("array"))
|
||||
if name != "" {
|
||||
cur, _ := cg.getLocal(name).([]Value)
|
||||
arr := append([]Value{}, cur...)
|
||||
var path []int
|
||||
ok := true
|
||||
for _, s := range strings.Split(node.param("index"), ",") {
|
||||
f := EvalExpr(strings.TrimSpace(s), ctx.resolve)
|
||||
if math.IsNaN(f) {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
path = append(path, int(f))
|
||||
}
|
||||
val := EvalValue(node.param("expr"), ctx.resolve)
|
||||
if ok && len(path) > 0 {
|
||||
arr = setPath(arr, path, val)
|
||||
}
|
||||
cg.setLocal(name, arr)
|
||||
}
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.array.remove":
|
||||
name := strings.TrimSpace(node.param("array"))
|
||||
if name != "" {
|
||||
cur, _ := cg.getLocal(name).([]Value)
|
||||
arr := append([]Value{}, cur...)
|
||||
i := int(EvalExpr(node.param("index"), ctx.resolve))
|
||||
k := i
|
||||
if k < 0 {
|
||||
k = len(arr) + k
|
||||
}
|
||||
if k >= 0 && k < len(arr) {
|
||||
arr = append(arr[:k], arr[k+1:]...)
|
||||
}
|
||||
cg.setLocal(name, arr)
|
||||
}
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.array.pop":
|
||||
name := strings.TrimSpace(node.param("array"))
|
||||
if name != "" {
|
||||
cur, _ := cg.getLocal(name).([]Value)
|
||||
arr := append([]Value{}, cur...)
|
||||
if len(arr) > 0 {
|
||||
arr = arr[:len(arr)-1]
|
||||
}
|
||||
cg.setLocal(name, arr)
|
||||
}
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.array.clear":
|
||||
name := strings.TrimSpace(node.param("array"))
|
||||
if name != "" {
|
||||
cg.setLocal(name, []Value{}) // setLocal applies sizing (fixed → zero-pad)
|
||||
}
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
default:
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ func TestCoerceParamValue(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSign(t *testing.T) {
|
||||
if sign(5) != 1 || sign(-5) != -1 || sign(0) != 0 {
|
||||
t.Errorf("sign mismatch: %d %d %d", sign(5), sign(-5), sign(0))
|
||||
if signOf(5) != 1 || signOf(-5) != -1 || signOf(0) != 0 {
|
||||
t.Errorf("sign mismatch: %d %d %d", signOf(5), signOf(-5), signOf(0))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// runFlowOnce compiles g, wires a minimal engine/context onto the compiled graph
|
||||
// (enough for the run/follow path and the lock-free emitDebug guard), then drives
|
||||
// the flow from triggerID synchronously and returns the compiled graph so callers
|
||||
// can inspect locals.
|
||||
func runFlowOnce(t *testing.T, g Graph, triggerID string) *compiledGraph {
|
||||
t.Helper()
|
||||
cg := compile(g)
|
||||
cg.engine = &Engine{}
|
||||
cg.genCtx = context.Background()
|
||||
R := func(ds, name string) Value {
|
||||
switch ds {
|
||||
case "local":
|
||||
return cg.getLocal(name)
|
||||
default:
|
||||
return 0.0
|
||||
}
|
||||
}
|
||||
ctx := &runCtx{fired: triggerID, resolve: R}
|
||||
cg.follow(triggerID, "out", ctx)
|
||||
return cg
|
||||
}
|
||||
|
||||
func TestArrayPushNode(t *testing.T) {
|
||||
g := Graph{
|
||||
ID: "g", Name: "n",
|
||||
StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[1]", Sizing: "dynamic"}},
|
||||
Nodes: []Node{
|
||||
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
|
||||
{ID: "p", Kind: "action.array.push", Params: map[string]string{"array": "buf", "expr": "5"}},
|
||||
},
|
||||
Wires: []Wire{{From: "t", To: "p"}},
|
||||
}
|
||||
cg := runFlowOnce(t, g, "t")
|
||||
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{1.0, 5.0}) {
|
||||
t.Fatalf("after push buf = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArrayClearNode(t *testing.T) {
|
||||
g := Graph{
|
||||
ID: "g", Name: "n",
|
||||
StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[1,2,3]", Sizing: "fixed", Capacity: 3}},
|
||||
Nodes: []Node{
|
||||
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
|
||||
{ID: "c", Kind: "action.array.clear", Params: map[string]string{"array": "buf"}},
|
||||
},
|
||||
Wires: []Wire{{From: "t", To: "c"}},
|
||||
}
|
||||
cg := runFlowOnce(t, g, "t")
|
||||
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{0.0, 0.0, 0.0}) {
|
||||
t.Fatalf("after clear buf = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArraySetNode(t *testing.T) {
|
||||
g := Graph{
|
||||
ID: "g", Name: "n",
|
||||
StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[0,0,0]", Sizing: "dynamic"}},
|
||||
Nodes: []Node{
|
||||
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
|
||||
{ID: "s", Kind: "action.array.set", Params: map[string]string{"array": "buf", "index": "1", "expr": "9"}},
|
||||
},
|
||||
Wires: []Wire{{From: "t", To: "s"}},
|
||||
}
|
||||
cg := runFlowOnce(t, g, "t")
|
||||
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{0.0, 9.0, 0.0}) {
|
||||
t.Fatalf("after set buf = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArrayRemoveNode(t *testing.T) {
|
||||
g := Graph{
|
||||
ID: "g", Name: "n",
|
||||
StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[10,20,30]", Sizing: "dynamic"}},
|
||||
Nodes: []Node{
|
||||
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
|
||||
{ID: "r", Kind: "action.array.remove", Params: map[string]string{"array": "buf", "index": "-1"}},
|
||||
},
|
||||
Wires: []Wire{{From: "t", To: "r"}},
|
||||
}
|
||||
cg := runFlowOnce(t, g, "t")
|
||||
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{10.0, 20.0}) {
|
||||
t.Fatalf("after remove buf = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArrayPopNode(t *testing.T) {
|
||||
g := Graph{
|
||||
ID: "g", Name: "n",
|
||||
StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[1,2,3]", Sizing: "dynamic"}},
|
||||
Nodes: []Node{
|
||||
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
|
||||
{ID: "p", Kind: "action.array.pop", Params: map[string]string{"array": "buf"}},
|
||||
},
|
||||
Wires: []Wire{{From: "t", To: "p"}},
|
||||
}
|
||||
cg := runFlowOnce(t, g, "t")
|
||||
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{1.0, 2.0}) {
|
||||
t.Fatalf("after pop buf = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestArraySetNodeNested verifies set with a comma-separated path mutates a nested
|
||||
// sub-array, and (crucially) that setPath does not mutate the previously stored slice
|
||||
// in place — the stored value must be replaced by a fresh tree.
|
||||
func TestArraySetNodeNested(t *testing.T) {
|
||||
g := Graph{
|
||||
ID: "g", Name: "n",
|
||||
StateVars: []StateVar{{Name: "grid", Type: "array", Initial: "[[1,2],[3,4]]", Sizing: "dynamic"}},
|
||||
Nodes: []Node{
|
||||
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
|
||||
{ID: "s", Kind: "action.array.set", Params: map[string]string{"array": "grid", "index": "0,1", "expr": "9"}},
|
||||
},
|
||||
Wires: []Wire{{From: "t", To: "s"}},
|
||||
}
|
||||
cg := compile(g)
|
||||
before := cg.getLocal("grid")
|
||||
cg.engine = &Engine{}
|
||||
cg.genCtx = context.Background()
|
||||
R := func(ds, name string) Value {
|
||||
if ds == "local" {
|
||||
return cg.getLocal(name)
|
||||
}
|
||||
return 0.0
|
||||
}
|
||||
cg.follow("t", "out", &runCtx{fired: "t", resolve: R})
|
||||
want := []Value{[]Value{1.0, 9.0}, []Value{3.0, 4.0}}
|
||||
if got := cg.getLocal("grid"); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("after nested set grid = %#v", got)
|
||||
}
|
||||
// The pre-mutation snapshot must be untouched (no shared-backing in-place write).
|
||||
if !reflect.DeepEqual(before, []Value{[]Value{1.0, 2.0}, []Value{3.0, 4.0}}) {
|
||||
t.Fatalf("setPath mutated the prior stored value in place: %#v", before)
|
||||
}
|
||||
}
|
||||
|
||||
// TestArraySetNodeConcurrentNoRace drives many concurrent set+read flows against the
|
||||
// same nested-array local; with -race it guards the setPath copy-on-descend fix.
|
||||
func TestArraySetNodeConcurrentNoRace(t *testing.T) {
|
||||
g := Graph{
|
||||
ID: "g", Name: "n",
|
||||
StateVars: []StateVar{{Name: "grid", Type: "array", Initial: "[[0,0],[0,0]]", Sizing: "dynamic"}},
|
||||
Nodes: []Node{
|
||||
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
|
||||
{ID: "s", Kind: "action.array.set", Params: map[string]string{"array": "grid", "index": "0,0", "expr": "1"}},
|
||||
},
|
||||
Wires: []Wire{{From: "t", To: "s"}},
|
||||
}
|
||||
cg := compile(g)
|
||||
cg.engine = &Engine{}
|
||||
cg.genCtx = context.Background()
|
||||
R := func(ds, name string) Value {
|
||||
if ds == "local" {
|
||||
return cg.getLocal(name)
|
||||
}
|
||||
return 0.0
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 32; i++ {
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); cg.follow("t", "out", &runCtx{fired: "t", resolve: R}) }()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
// Read the inner element concurrently; with the in-place setPath bug the
|
||||
// writer mutates this same sub-array backing, producing a data race.
|
||||
if arr, ok := cg.getLocal("grid").([]Value); ok && len(arr) > 0 {
|
||||
if sub, ok := arr[0].([]Value); ok && len(sub) > 0 {
|
||||
_ = sub[0]
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestCompileInitsLocalsFromDecls(t *testing.T) {
|
||||
g := Graph{
|
||||
ID: "g", Name: "n",
|
||||
StateVars: []StateVar{
|
||||
{Name: "count", Type: "number", Initial: "7"},
|
||||
{Name: "flag", Type: "bool", Initial: "true"},
|
||||
{Name: "buf", Type: "array", Initial: "[1,2,3]", Sizing: "capped", Capacity: 4},
|
||||
},
|
||||
}
|
||||
cg := compile(g)
|
||||
if got := cg.getLocal("count"); got != Value(7.0) {
|
||||
t.Fatalf("count = %#v", got)
|
||||
}
|
||||
if got := cg.getLocal("flag"); got != Value(1.0) {
|
||||
t.Fatalf("flag = %#v", got)
|
||||
}
|
||||
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{1.0, 2.0, 3.0}) {
|
||||
t.Fatalf("buf = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetLocalAppliesSizing(t *testing.T) {
|
||||
g := Graph{ID: "g", Name: "n", StateVars: []StateVar{
|
||||
{Name: "buf", Type: "array", Initial: "[]", Sizing: "capped", Capacity: 2},
|
||||
}}
|
||||
cg := compile(g)
|
||||
cg.setLocal("buf", []Value{1.0, 2.0, 3.0, 4.0})
|
||||
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{3.0, 4.0}) {
|
||||
t.Fatalf("sized buf = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverReturnsLocalValue(t *testing.T) {
|
||||
g := Graph{ID: "g", Name: "n", StateVars: []StateVar{
|
||||
{Name: "buf", Type: "array", Initial: "[10,20]", Sizing: "dynamic"},
|
||||
}}
|
||||
cg := compile(g)
|
||||
R := func(ds, name string) Value {
|
||||
if ds == "local" {
|
||||
return cg.getLocal(name)
|
||||
}
|
||||
return 0.0
|
||||
}
|
||||
if v := EvalExpr("buf[1]", R); v != 20 {
|
||||
t.Fatalf("buf[1] = %v", v)
|
||||
}
|
||||
if v := EvalExpr("sum(buf)", R); v != 30 {
|
||||
t.Fatalf("sum(buf) = %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitDebugAcceptsArray(t *testing.T) {
|
||||
g := Graph{ID: "g", Name: "n"}
|
||||
cg := compile(g)
|
||||
cg.engine = &Engine{} // no observer installed; emitDebug must not panic
|
||||
cg.emitDebug("x", []Value{1.0, 2.0}, true)
|
||||
cg.emitDebug("x", 3.0, true)
|
||||
}
|
||||
@@ -353,8 +353,6 @@ func signOf(x float64) int {
|
||||
}
|
||||
}
|
||||
|
||||
// sign is an alias for signOf, kept for backward compatibility with existing tests.
|
||||
func sign(x float64) int { return signOf(x) }
|
||||
func clampIdx(i, length int) int {
|
||||
if i < 0 {
|
||||
i = length + i
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func TestEvalExpr(t *testing.T) {
|
||||
resolve := func(ds, name string) Value { // shim: was float64 (Task 4 removes)
|
||||
resolve := func(ds, name string) Value {
|
||||
switch {
|
||||
case ds == "stub" && name == "x":
|
||||
return 10
|
||||
@@ -46,7 +46,7 @@ func TestEvalExpr(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEvalExprErrors(t *testing.T) {
|
||||
r := func(ds, name string) Value { return 0 } // shim: was float64 (Task 4 removes)
|
||||
r := func(ds, name string) Value { return 0 }
|
||||
for _, bad := range []string{"1 +", "(1", "1 2", "{unterminated"} {
|
||||
if v := EvalExpr(bad, r); !math.IsNaN(v) {
|
||||
t.Errorf("EvalExpr(%q) = %v, want NaN", bad, v)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sync"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
@@ -60,9 +61,9 @@ func (lr *luaRuntime) ensure() error {
|
||||
L.SetGlobal("get", L.NewFunction(func(s *lua.LState) int {
|
||||
target := s.CheckString(1)
|
||||
ds, name, ok := parseRef(target)
|
||||
var v float64
|
||||
if ok && lr.curResolve != nil { // shim: Task 6 finalizes lua Value handling
|
||||
if f, fok := lr.curResolve(ds, name).(float64); fok {
|
||||
v := math.NaN()
|
||||
if ok && lr.curResolve != nil {
|
||||
if f, isNum := lr.curResolve(ds, name).(float64); isNum {
|
||||
v = f
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ type debugOut struct {
|
||||
Type string `json:"type"` // always "debugNode"
|
||||
GraphID string `json:"graphId"`
|
||||
NodeID string `json:"nodeId"`
|
||||
Value float64 `json:"value"`
|
||||
Value any `json:"value"`
|
||||
HasValue bool `json:"hasValue"`
|
||||
TS int64 `json:"ts"`
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import { formatBadge, badgeTitle, nodeDebugClass, type DebugSnapshot } from './l
|
||||
import { wsClient } from './lib/ws';
|
||||
import { useAuth } from './lib/auth';
|
||||
import { ScopeFilter, ScopePicker, filterByScope, normalizeScope, type Bucket } from './lib/scope';
|
||||
import { LocalVars } from './LogicEditor';
|
||||
import type { StateVar } from './lib/types';
|
||||
|
||||
// ── Types (mirror internal/controllogic/model.go) ────────────────────────────
|
||||
|
||||
@@ -27,6 +29,11 @@ type CLNodeKind =
|
||||
| 'action.log'
|
||||
| 'action.lua'
|
||||
| 'action.dialog'
|
||||
| 'action.array.push'
|
||||
| 'action.array.set'
|
||||
| 'action.array.remove'
|
||||
| 'action.array.pop'
|
||||
| 'action.array.clear'
|
||||
| 'action.config.apply'
|
||||
| 'action.config.read'
|
||||
| 'action.config.write'
|
||||
@@ -51,6 +58,7 @@ interface CLGraph {
|
||||
owner?: string;
|
||||
scope?: string;
|
||||
scopeGroups?: string[];
|
||||
statevars?: StateVar[];
|
||||
}
|
||||
|
||||
interface DataSource { name: string; }
|
||||
@@ -101,6 +109,11 @@ const PALETTE: PaletteEntry[] = [
|
||||
{ kind: 'action.log', label: 'Log', params: { expr: '', label: '' } },
|
||||
{ kind: 'action.lua', label: 'Lua script', params: { script: '-- get("ds:name") reads, set("ds:name", v) writes,\n-- log("msg") logs to the server.\n' } },
|
||||
{ kind: 'action.dialog', label: 'Dialog', params: { kind: 'info', title: '', message: '', users: '', groups: '', target: '' } },
|
||||
{ kind: 'action.array.push', label: 'Array push', params: { array: '', expr: '' } },
|
||||
{ kind: 'action.array.set', label: 'Array set', params: { array: '', index: '0', expr: '' } },
|
||||
{ kind: 'action.array.remove', label: 'Array remove', params: { array: '', index: '0' } },
|
||||
{ kind: 'action.array.pop', label: 'Array pop', params: { array: '' } },
|
||||
{ kind: 'action.array.clear', label: 'Array clear', params: { array: '' } },
|
||||
{ kind: 'action.config.apply', label: 'Apply config', params: { instance: '' } },
|
||||
{ kind: 'action.config.read', label: 'Read config', params: { instance: '', key: '', target: '' } },
|
||||
{ kind: 'action.config.write', label: 'Write config', params: { instance: '', key: '', expr: '' } },
|
||||
@@ -122,6 +135,11 @@ const KIND_LABEL: Record<CLNodeKind, string> = {
|
||||
'action.log': 'Log',
|
||||
'action.lua': 'Lua script',
|
||||
'action.dialog': 'Dialog',
|
||||
'action.array.push': 'Array push',
|
||||
'action.array.set': 'Array set',
|
||||
'action.array.remove': 'Array remove',
|
||||
'action.array.pop': 'Array pop',
|
||||
'action.array.clear': 'Array clear',
|
||||
'action.config.apply': 'Apply config',
|
||||
'action.config.read': 'Read config',
|
||||
'action.config.write': 'Write config',
|
||||
@@ -377,6 +395,9 @@ export default function ControlLogicEditor({ onClose }: Props) {
|
||||
<ScopePicker me={me} scope={normalizeScope(graph.scope)} groups={graph.scopeGroups ?? []}
|
||||
onChange={(scope, scopeGroups) => patchGraph({ scope, scopeGroups })} />
|
||||
<span class="hint">Saving a disabled graph stops it; enabling + saving starts it.</span>
|
||||
<LocalVars
|
||||
statevars={graph.statevars ?? []}
|
||||
onChange={(vars) => patchGraph({ statevars: vars })} />
|
||||
</div>
|
||||
<div class="cl-editor-main">
|
||||
<FlowEditor graph={graph}
|
||||
@@ -1158,6 +1179,51 @@ function FlowEditor({ graph, onChange }: {
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{(selected.kind === 'action.array.push' || selected.kind === 'action.array.set' ||
|
||||
selected.kind === 'action.array.remove' || selected.kind === 'action.array.pop' ||
|
||||
selected.kind === 'action.array.clear') && (() => {
|
||||
const arrayLocals = (graph.statevars ?? []).filter(v => v.type === 'array');
|
||||
const cur = selected.params.array ?? '';
|
||||
return (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Array (local variable)</label>
|
||||
<select class="prop-select" value={cur}
|
||||
onChange={(e) => patchParams(selected.id, { array: (e.target as HTMLSelectElement).value })}>
|
||||
<option value="">— select array —</option>
|
||||
{arrayLocals.map(v => <option key={v.name} value={v.name}>{v.name}</option>)}
|
||||
{cur && !arrayLocals.some(v => v.name === cur) && <option value={cur}>{cur} (unknown)</option>}
|
||||
</select>
|
||||
{arrayLocals.length === 0 && (
|
||||
<p class="hint">No array locals declared yet — add one in the Local vars panel.</p>
|
||||
)}
|
||||
</div>
|
||||
{(selected.kind === 'action.array.set' || selected.kind === 'action.array.remove') && (
|
||||
<div class="wizard-field">
|
||||
<label>Index{selected.kind === 'action.array.set' ? ' (comma-separated for nested)' : ''}</label>
|
||||
<input class="prop-input" value={selected.params.index ?? ''}
|
||||
placeholder="e.g. 0 or 2,1"
|
||||
onInput={(e) => patchParams(selected.id, { index: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
)}
|
||||
{(selected.kind === 'action.array.push' || selected.kind === 'action.array.set') && (
|
||||
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
|
||||
onChange={(v) => patchParams(selected.id, { expr: v })}
|
||||
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
|
||||
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
|
||||
hint={selected.kind === 'action.array.push'
|
||||
? 'Value to append to the end of the array.'
|
||||
: 'Value to store at the given index / path.'} />
|
||||
)}
|
||||
{(selected.kind === 'action.array.pop' || selected.kind === 'action.array.clear') && (
|
||||
<p class="hint">{selected.kind === 'action.array.pop'
|
||||
? 'Removes and returns the last element of the array.'
|
||||
: 'Removes all elements from the array.'}</p>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})()}
|
||||
|
||||
{selected.kind === 'action.delay' && (
|
||||
<div class="wizard-field">
|
||||
<label>Delay (ms)</label>
|
||||
@@ -1426,6 +1492,11 @@ function nodeSummary(n: CLNode): string {
|
||||
case 'action.log': return `log ${n.params.label ? n.params.label + ': ' : ''}${n.params.expr || ''}`;
|
||||
case 'action.lua': return 'Lua script';
|
||||
case 'action.dialog': return `${n.params.kind || 'info'} dialog${n.params.title ? ': ' + n.params.title : ''}`;
|
||||
case 'action.array.push': return `push ${n.params.expr || ''} → ${n.params.array || '?'}`;
|
||||
case 'action.array.set': return `${n.params.array || '?'}[${n.params.index || '?'}] = ${n.params.expr || ''}`;
|
||||
case 'action.array.remove': return `remove ${n.params.array || '?'}[${n.params.index || '?'}]`;
|
||||
case 'action.array.pop': return `pop ${n.params.array || '?'}`;
|
||||
case 'action.array.clear': return `clear ${n.params.array || '?'}`;
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1448,7 +1448,7 @@ function nodeSummary(n: LogicNode): string {
|
||||
|
||||
// Inline editor for the panel's local state variables, shown in the logic
|
||||
// palette so they can be created without leaving the (full-width) logic tab.
|
||||
function LocalVars({ statevars, onChange }: {
|
||||
export function LocalVars({ statevars, onChange }: {
|
||||
statevars: StateVar[];
|
||||
onChange: (vars: StateVar[]) => void;
|
||||
}) {
|
||||
|
||||
Reference in New Issue
Block a user