Compare commits
8 Commits
519c1f2df4
...
336095c052
| 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:
|
- [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:
|
- [ ] 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`
|
- [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
|
- [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:
|
- [x] add full suppor to local array values: dynamic, dynamic but capped max, fixed size etc:
|
||||||
- array functions should work with new local array
|
- [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). Box stays unchecked until **Phase 2** (server-side `internal/controllogic` Go port, see below) lands.
|
- 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.
|
||||||
- [ ] Control loop:
|
- [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
|
- [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] Implement git style versioning for: synthetic variable, panels, control logic:
|
||||||
- [x] possibility to fork any version
|
- [x] possibility to fork any version
|
||||||
- [x] click to view the 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
|
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=…>`).
|
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
|
> **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 a separate
|
> support in the server-side control-logic engine (`internal/controllogic`) is Phase 2 and has
|
||||||
> Phase 2 effort and is not yet implemented.
|
> now landed — see the **Array-valued local variables** subsection of §3.9 below.
|
||||||
|
|
||||||
### 3.9 Control Logic Engine
|
### 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
|
`/api/v1/controllogic`; each mutation calls `Engine.Reload()` to apply changes live. Graphs
|
||||||
can be individually enabled/disabled.
|
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
|
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
|
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
|
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 1.0
|
||||||
}
|
}
|
||||||
return 0.0
|
return 0.0
|
||||||
case []interface{}:
|
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)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
case []Value:
|
|
||||||
out := make([]Value, len(t))
|
out := make([]Value, len(t))
|
||||||
for i, e := range t {
|
for i, e := range t {
|
||||||
out[i] = normalizeValue(e)
|
out[i] = normalizeValue(e)
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ func setupConfigEngine(t *testing.T) (*Engine, *writableSource, string) {
|
|||||||
|
|
||||||
func TestApplyConfigWritesEveryParam(t *testing.T) {
|
func TestApplyConfigWritesEveryParam(t *testing.T) {
|
||||||
e, src, instID := setupConfigEngine(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)
|
e.applyConfig(cg, instID)
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ func TestApplyConfigWritesEveryParam(t *testing.T) {
|
|||||||
|
|
||||||
func TestApplyConfigUnknownInstanceNoop(t *testing.T) {
|
func TestApplyConfigUnknownInstanceNoop(t *testing.T) {
|
||||||
e, src, _ := setupConfigEngine(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")
|
e.applyConfig(cg, "does-not-exist")
|
||||||
|
|
||||||
@@ -144,7 +144,7 @@ func TestReadConfigParam(t *testing.T) {
|
|||||||
|
|
||||||
func TestWriteConfigParam(t *testing.T) {
|
func TestWriteConfigParam(t *testing.T) {
|
||||||
e, _, instID := setupConfigEngine(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)
|
e.writeConfigParam(cg, instID, "gain", 7.5)
|
||||||
|
|
||||||
@@ -163,7 +163,7 @@ func TestWriteConfigParam(t *testing.T) {
|
|||||||
|
|
||||||
func TestCreateConfigInstance(t *testing.T) {
|
func TestCreateConfigInstance(t *testing.T) {
|
||||||
e, _, srcID := setupConfigEngine(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)
|
src, err := e.cfg.GetInstance(srcID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -197,7 +197,7 @@ func TestCreateConfigInstance(t *testing.T) {
|
|||||||
|
|
||||||
func TestSnapshotConfig(t *testing.T) {
|
func TestSnapshotConfig(t *testing.T) {
|
||||||
e, src, instID := setupConfigEngine(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.
|
// Seed current live values for the set's target signals.
|
||||||
_ = src.Write(context.Background(), "GAIN", 3.5)
|
_ = src.Write(context.Background(), "GAIN", 3.5)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
type DebugEvent struct {
|
type DebugEvent struct {
|
||||||
GraphID string `json:"graphId"`
|
GraphID string `json:"graphId"`
|
||||||
NodeID string `json:"nodeId"`
|
NodeID string `json:"nodeId"`
|
||||||
Value float64 `json:"value"`
|
Value any `json:"value"`
|
||||||
HasValue bool `json:"hasValue"`
|
HasValue bool `json:"hasValue"`
|
||||||
TS int64 `json:"ts"` // unix millis
|
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
|
// 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).
|
// (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 {
|
if !cg.alwaysDebug {
|
||||||
w, _ := cg.engine.debugWatch.Load().(map[string]bool)
|
w, _ := cg.engine.debugWatch.Load().(map[string]bool)
|
||||||
if !w[cg.id] {
|
if !w[cg.id] {
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ func TestDebugObserverCapturesNodeValues(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("no event for write node 'w'")
|
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)
|
t.Errorf("write node event = %+v, want value 42 hasValue true", wEv)
|
||||||
}
|
}
|
||||||
if wEv.GraphID != "g1" {
|
if wEv.GraphID != "g1" {
|
||||||
@@ -136,7 +136,7 @@ func TestFireTriggerForcesRun(t *testing.T) {
|
|||||||
}
|
}
|
||||||
time.Sleep(10 * time.Millisecond)
|
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)
|
t.Errorf("write node event = %+v, want value 42 hasValue true", wEv)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ func TestSimulateSuppressesWrites(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("simulate produced no event for write node 'w'")
|
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)
|
t.Errorf("simulate write event = %+v, want value 42 hasValue true", wEv)
|
||||||
}
|
}
|
||||||
if wEv.GraphID != "sim" {
|
if wEv.GraphID != "sim" {
|
||||||
|
|||||||
+150
-11
@@ -276,17 +276,51 @@ func (e *Engine) liveGet(ds, name string) float64 {
|
|||||||
return v
|
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
|
// 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.
|
// 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)
|
ds, name, ok := parseRef(target)
|
||||||
if !ok || math.IsNaN(val) {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ds == "local" {
|
if ds == "local" {
|
||||||
cg.setLocal(name, val)
|
cg.setLocal(name, val)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
f, isNum := val.(float64)
|
||||||
|
if !isNum || math.IsNaN(f) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if cg.dryRun {
|
if cg.dryRun {
|
||||||
return // simulate: no real data-source write
|
return // simulate: no real data-source write
|
||||||
}
|
}
|
||||||
@@ -301,11 +335,11 @@ func (e *Engine) write(cg *compiledGraph, target string, val float64) {
|
|||||||
Action: "signal.write",
|
Action: "signal.write",
|
||||||
DS: ds,
|
DS: ds,
|
||||||
Signal: name,
|
Signal: name,
|
||||||
Value: strconv.FormatFloat(val, 'g', -1, 64),
|
Value: strconv.FormatFloat(f, 'g', -1, 64),
|
||||||
Detail: "control logic: " + cg.name,
|
Detail: "control logic: " + cg.name,
|
||||||
Outcome: audit.OutcomeOK,
|
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)
|
e.log.Warn("control logic: write failed", "ds", ds, "signal", name, "err", err)
|
||||||
ev.Outcome = audit.OutcomeError
|
ev.Outcome = audit.OutcomeError
|
||||||
ev.Error = err.Error()
|
ev.Error = err.Error()
|
||||||
@@ -608,7 +642,8 @@ type compiledGraph struct {
|
|||||||
prevVal map[string]float64 // last value for change triggers
|
prevVal map[string]float64 // last value for change triggers
|
||||||
hasVal map[string]bool
|
hasVal map[string]bool
|
||||||
lastFire map[string]int64 // ns wall clock each trigger last fired
|
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 {
|
func compile(g Graph) *compiledGraph {
|
||||||
@@ -626,12 +661,21 @@ func compile(g Graph) *compiledGraph {
|
|||||||
prevVal: map[string]float64{},
|
prevVal: map[string]float64{},
|
||||||
hasVal: map[string]bool{},
|
hasVal: map[string]bool{},
|
||||||
lastFire: map[string]int64{},
|
lastFire: map[string]int64{},
|
||||||
locals: map[string]float64{},
|
locals: map[string]Value{},
|
||||||
|
decls: map[string]StateVar{},
|
||||||
fireCh: make(chan string, 16),
|
fireCh: make(chan string, 16),
|
||||||
}
|
}
|
||||||
for _, n := range g.Nodes {
|
for _, n := range g.Nodes {
|
||||||
cg.byId[n.ID] = n
|
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 {
|
for _, w := range g.Wires {
|
||||||
port := w.FromPort
|
port := w.FromPort
|
||||||
if port == "" {
|
if port == "" {
|
||||||
@@ -672,6 +716,13 @@ func compile(g Graph) *compiledGraph {
|
|||||||
}
|
}
|
||||||
case "action.write", "action.log":
|
case "action.write", "action.log":
|
||||||
wantExpr(n.param("expr"))
|
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":
|
case "action.lua":
|
||||||
cg.luaNodes[n.ID] = newLuaRuntime(n.param("script"))
|
cg.luaNodes[n.ID] = newLuaRuntime(n.param("script"))
|
||||||
for _, r := range luaGetRefs(n.param("script")) {
|
for _, r := range luaGetRefs(n.param("script")) {
|
||||||
@@ -682,18 +733,38 @@ func compile(g Graph) *compiledGraph {
|
|||||||
return cg
|
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()
|
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.locals[name] = v
|
||||||
cg.stateMu.Unlock()
|
cg.stateMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cg *compiledGraph) getLocal(name string) float64 {
|
func (cg *compiledGraph) getLocal(name string) Value {
|
||||||
cg.stateMu.Lock()
|
cg.stateMu.Lock()
|
||||||
defer cg.stateMu.Unlock()
|
defer cg.stateMu.Unlock()
|
||||||
v, ok := cg.locals[name]
|
v, ok := cg.locals[name]
|
||||||
if !ok {
|
if !ok {
|
||||||
return 0
|
return 0.0
|
||||||
}
|
}
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
@@ -897,7 +968,7 @@ func (cg *compiledGraph) activate(triggerID string) {
|
|||||||
dt = float64(now-last) / 1e9
|
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 {
|
switch ds {
|
||||||
case "sys":
|
case "sys":
|
||||||
if name == "dt" {
|
if name == "dt" {
|
||||||
@@ -986,7 +1057,7 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
|
|||||||
cg.follow(node.ID, "done", ctx)
|
cg.follow(node.ID, "done", ctx)
|
||||||
|
|
||||||
case "action.write":
|
case "action.write":
|
||||||
val := EvalExpr(node.param("expr"), ctx.resolve)
|
val := EvalValue(node.param("expr"), ctx.resolve)
|
||||||
cg.emitDebug(node.ID, val, true)
|
cg.emitDebug(node.ID, val, true)
|
||||||
cg.engine.write(cg, node.param("target"), val)
|
cg.engine.write(cg, node.param("target"), val)
|
||||||
cg.follow(node.ID, "out", ctx)
|
cg.follow(node.ID, "out", ctx)
|
||||||
@@ -1057,6 +1128,74 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
|
|||||||
}
|
}
|
||||||
cg.follow(node.ID, "out", ctx)
|
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:
|
default:
|
||||||
cg.follow(node.ID, "out", ctx)
|
cg.follow(node.ID, "out", ctx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ func TestCoerceParamValue(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSign(t *testing.T) {
|
func TestSign(t *testing.T) {
|
||||||
if sign(5) != 1 || sign(-5) != -1 || sign(0) != 0 {
|
if signOf(5) != 1 || signOf(-5) != -1 || signOf(0) != 0 {
|
||||||
t.Errorf("sign mismatch: %d %d %d", sign(5), sign(-5), sign(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 {
|
func clampIdx(i, length int) int {
|
||||||
if i < 0 {
|
if i < 0 {
|
||||||
i = length + i
|
i = length + i
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestEvalExpr(t *testing.T) {
|
func TestEvalExpr(t *testing.T) {
|
||||||
resolve := func(ds, name string) Value { // shim: was float64 (Task 4 removes)
|
resolve := func(ds, name string) Value {
|
||||||
switch {
|
switch {
|
||||||
case ds == "stub" && name == "x":
|
case ds == "stub" && name == "x":
|
||||||
return 10
|
return 10
|
||||||
@@ -46,7 +46,7 @@ func TestEvalExpr(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestEvalExprErrors(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"} {
|
for _, bad := range []string{"1 +", "(1", "1 2", "{unterminated"} {
|
||||||
if v := EvalExpr(bad, r); !math.IsNaN(v) {
|
if v := EvalExpr(bad, r); !math.IsNaN(v) {
|
||||||
t.Errorf("EvalExpr(%q) = %v, want NaN", bad, v)
|
t.Errorf("EvalExpr(%q) = %v, want NaN", bad, v)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package controllogic
|
package controllogic
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
lua "github.com/yuin/gopher-lua"
|
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 {
|
L.SetGlobal("get", L.NewFunction(func(s *lua.LState) int {
|
||||||
target := s.CheckString(1)
|
target := s.CheckString(1)
|
||||||
ds, name, ok := parseRef(target)
|
ds, name, ok := parseRef(target)
|
||||||
var v float64
|
v := math.NaN()
|
||||||
if ok && lr.curResolve != nil { // shim: Task 6 finalizes lua Value handling
|
if ok && lr.curResolve != nil {
|
||||||
if f, fok := lr.curResolve(ds, name).(float64); fok {
|
if f, isNum := lr.curResolve(ds, name).(float64); isNum {
|
||||||
v = f
|
v = f
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ type debugOut struct {
|
|||||||
Type string `json:"type"` // always "debugNode"
|
Type string `json:"type"` // always "debugNode"
|
||||||
GraphID string `json:"graphId"`
|
GraphID string `json:"graphId"`
|
||||||
NodeID string `json:"nodeId"`
|
NodeID string `json:"nodeId"`
|
||||||
Value float64 `json:"value"`
|
Value any `json:"value"`
|
||||||
HasValue bool `json:"hasValue"`
|
HasValue bool `json:"hasValue"`
|
||||||
TS int64 `json:"ts"`
|
TS int64 `json:"ts"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { formatBadge, badgeTitle, nodeDebugClass, type DebugSnapshot } from './l
|
|||||||
import { wsClient } from './lib/ws';
|
import { wsClient } from './lib/ws';
|
||||||
import { useAuth } from './lib/auth';
|
import { useAuth } from './lib/auth';
|
||||||
import { ScopeFilter, ScopePicker, filterByScope, normalizeScope, type Bucket } from './lib/scope';
|
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) ────────────────────────────
|
// ── Types (mirror internal/controllogic/model.go) ────────────────────────────
|
||||||
|
|
||||||
@@ -27,6 +29,11 @@ type CLNodeKind =
|
|||||||
| 'action.log'
|
| 'action.log'
|
||||||
| 'action.lua'
|
| 'action.lua'
|
||||||
| 'action.dialog'
|
| 'action.dialog'
|
||||||
|
| 'action.array.push'
|
||||||
|
| 'action.array.set'
|
||||||
|
| 'action.array.remove'
|
||||||
|
| 'action.array.pop'
|
||||||
|
| 'action.array.clear'
|
||||||
| 'action.config.apply'
|
| 'action.config.apply'
|
||||||
| 'action.config.read'
|
| 'action.config.read'
|
||||||
| 'action.config.write'
|
| 'action.config.write'
|
||||||
@@ -51,6 +58,7 @@ interface CLGraph {
|
|||||||
owner?: string;
|
owner?: string;
|
||||||
scope?: string;
|
scope?: string;
|
||||||
scopeGroups?: string[];
|
scopeGroups?: string[];
|
||||||
|
statevars?: StateVar[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DataSource { name: string; }
|
interface DataSource { name: string; }
|
||||||
@@ -101,6 +109,11 @@ const PALETTE: PaletteEntry[] = [
|
|||||||
{ kind: 'action.log', label: 'Log', params: { expr: '', label: '' } },
|
{ 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.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.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.apply', label: 'Apply config', params: { instance: '' } },
|
||||||
{ kind: 'action.config.read', label: 'Read config', params: { instance: '', key: '', target: '' } },
|
{ kind: 'action.config.read', label: 'Read config', params: { instance: '', key: '', target: '' } },
|
||||||
{ kind: 'action.config.write', label: 'Write config', params: { instance: '', key: '', expr: '' } },
|
{ 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.log': 'Log',
|
||||||
'action.lua': 'Lua script',
|
'action.lua': 'Lua script',
|
||||||
'action.dialog': 'Dialog',
|
'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.apply': 'Apply config',
|
||||||
'action.config.read': 'Read config',
|
'action.config.read': 'Read config',
|
||||||
'action.config.write': 'Write 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 ?? []}
|
<ScopePicker me={me} scope={normalizeScope(graph.scope)} groups={graph.scopeGroups ?? []}
|
||||||
onChange={(scope, scopeGroups) => patchGraph({ scope, scopeGroups })} />
|
onChange={(scope, scopeGroups) => patchGraph({ scope, scopeGroups })} />
|
||||||
<span class="hint">Saving a disabled graph stops it; enabling + saving starts it.</span>
|
<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>
|
||||||
<div class="cl-editor-main">
|
<div class="cl-editor-main">
|
||||||
<FlowEditor graph={graph}
|
<FlowEditor graph={graph}
|
||||||
@@ -1158,6 +1179,51 @@ function FlowEditor({ graph, onChange }: {
|
|||||||
</Fragment>
|
</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' && (
|
{selected.kind === 'action.delay' && (
|
||||||
<div class="wizard-field">
|
<div class="wizard-field">
|
||||||
<label>Delay (ms)</label>
|
<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.log': return `log ${n.params.label ? n.params.label + ': ' : ''}${n.params.expr || ''}`;
|
||||||
case 'action.lua': return 'Lua script';
|
case 'action.lua': return 'Lua script';
|
||||||
case 'action.dialog': return `${n.params.kind || 'info'} dialog${n.params.title ? ': ' + n.params.title : ''}`;
|
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 '';
|
default: return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1448,7 +1448,7 @@ function nodeSummary(n: LogicNode): string {
|
|||||||
|
|
||||||
// Inline editor for the panel's local state variables, shown in the logic
|
// 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.
|
// palette so they can be created without leaving the (full-width) logic tab.
|
||||||
function LocalVars({ statevars, onChange }: {
|
export function LocalVars({ statevars, onChange }: {
|
||||||
statevars: StateVar[];
|
statevars: StateVar[];
|
||||||
onChange: (vars: StateVar[]) => void;
|
onChange: (vars: StateVar[]) => void;
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
Reference in New Issue
Block a user