Skip to main content

ControlForge Simple Mode (Zero-Code WHEN/THEN Rules)

June 2026 | ControlForge v1.0.1069

Simple Mode is the zero-code layer of ControlForge: you name your devices as points, write standing WHEN/THEN rules and ordered sequences as plain data, and the runtime compiles that data into real Structured Text and runs it on the ordinary scan path. There is no ST to hand-write — editing the model is the whole job. The generated program is a genuine POU you can inspect in Engineer Mode; it just happens to be machine-written from your rules.

A model is data, not code: named points, a table of rules, optional step sequences, plus alarm and notification surfaces. You PUT the model, compile it (validate with no side effects), deploy it (compile + push live), and check status (the deployed engine reports witnesses back so you know it actually loaded). This guide walks that loop end to end.

There are no SIMPLE_* ST functions — Simple Mode is driven entirely through the simple-mode HTTP endpoints (tag simple-mode in GET /api/openapi.json). The form-driven editor in the web IDE is a front end over exactly these endpoints.


The model — points, rules, sequences

A model has up to five lists. Only points is required.

{
"points": [ ... ], // named devices the engine reads/writes
"rules": [ ... ], // standing WHEN/THEN monitors + interlocks
"sequences": [ ... ], // ordered step procedures (optional)
"alerts": [ ... ], // threshold alarms over a point (optional)
"channels": [ ... ] // outbound webhook notifications (optional)
}

Points

A point is a named device or signal. You name it; the runtime owns the tag name behind it.

FieldValuesMeaning
namestringhuman name ("Cold Room temp", "supply pump")
kindanalog | on_offREAL value vs BOOL
dirin | outin = the engine reads it (sensor/status); out = the engine writes it (command/setpoint)
descstring (optional)free-text note
{ "name": "Cold Room temp", "kind": "analog", "dir": "in" },
{ "name": "supply pump", "kind": "on_off", "dir": "out" }

Conditions may only read in points; actions may only drive out points — the validator enforces this. Caps: 32 points per bank (analog-in / on/off-in / analog-out / on/off-out separately).

Rules — WHEN this, THEN that

A rule is a standing monitor: every scan, WHEN its condition holds, it applies every THEN action. Level conditions apply their actions every scan they hold (after the optional for_s debounce); edge conditions fire once per transition.

Conditions (when.cond):

condReadsTrue when
rises_toan analog in pointpoint >= value
falls_toan analog in pointpoint <= value
is_onan on/off in pointpoint is ON
is_offan on/off in pointpoint is OFF
turns_onan on/off in pointpoint transitions OFF→ON (edge)
turns_offan on/off in pointpoint transitions ON→OFF (edge)
after_timetime-in-step >= value seconds — sequences only, not rules
holdnever true (terminal hold step — sequences only)

value is the threshold (for rises_to/falls_to) or seconds (for after_time). for_s is an optional debounce: a level condition must hold that long before it counts (edge conditions ignore it).

Actions (then[].do):

doDrivesEffect
setan analog out pointset it to value
turn_onan on/off out pointturn it ON
turn_offan on/off out pointturn it OFF
rampan analog out pointramp from its step-entry value to value over over_s seconds — steps only, not rules
noneempty slot

A rule example — interlock the pump on over-temperature:

{
"name": "High temp -> pump on",
"when": { "cond": "rises_to", "point": "Cold Room temp", "value": 8.0, "for_s": 2 },
"then": [ { "do": "turn_on", "point": "supply pump" } ]
}

Caps: 32 rules; up to 4 actions per rule.

Sequences — ordered procedures

A sequence runs one step at a time. While a step is active its do actions run every scan; the sequence advances to the next step when the step's until condition is met. after_time and ramp become available because a step has a time base. timeout_s (0 = none) bounds a step — on timeout the sequence jumps to abort_step and flags timed_out.

{
"name": "Startup",
"abort_step": "Safe stop",
"steps": [
{
"name": "Prime",
"do": [ { "do": "turn_on", "point": "supply pump" } ],
"until": { "cond": "after_time", "value": 5 },
"timeout_s": 10
},
{
"name": "Run to temp",
"until": { "cond": "falls_to", "point": "Cold Room temp", "value": 4.0 }
},
{
"name": "Safe stop",
"do": [ { "do": "turn_off", "point": "supply pump" } ],
"until": { "cond": "hold" }
}
]
}

Caps: 4 sequences; 32 steps each; up to 4 actions per step. A hold step is the terminal "stay here forever" phase.


Step 1 — store the model: PUT /api/simple/model

The body is the model JSON above. It is validated (readable errors — unknown condition "rises_too", point "X" is an output — conditions read inputs) and, if valid, stored as the active model. The model is not running yet.

TOKEN=$(curl -s -X POST http://localhost:8302/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"goplc","password":"goplc"}' | jq -r .token)

curl -s -X PUT http://localhost:8302/api/simple/model \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d @model.json | jq

Success returns the accepted counts:

{ "status": "ok", "points": 2, "rules": 1, "sequences": 1 }

A validation failure returns 400 with { "status": "error", "error": "<readable reason>" }.

GET /api/simple/model returns the active model { "model": { ... } } (or { "model": { "points": null } } when none is stored).


Step 2 — compile it: POST /api/simple/compile

Generates the ST bundle from the active model and validates it through the same validator hand-written programs go through — with no side effects (nothing is deployed). This is your dry run. Add ?include_source=true to see the generated ST per POU.

curl -s -X POST "http://localhost:8302/api/simple/compile" \
-H "Authorization: Bearer $TOKEN" | jq
{
"valid": true,
"task": "SimpleMode",
"scan_ms": 100,
"pous": [
{ "name": "...", "kind": "gvl", "in_task": false, "lines": 24 },
{ "name": "...", "kind": "program", "in_task": true, "lines": 88 }
],
"fingerprints": { "rules": 1734092113, "sequences": { "Startup": { ... } } }
}

If generation fails, valid is false with an error (still HTTP 200 — a failed compile is a normal answer, not an error response). The fingerprints are the witness values the live engine must report once it loads — keep them in mind for status.


Step 3 — deploy it: POST /api/simple/deploy

Compiles the active model and pushes it live: stores every generated POU through the ordinary program path (auto-naming, project auto-save, events — everything Engineer Mode shows is real), sets the SimpleMode task's program list in scan order (creating the task on first deploy), then reloads and starts it. It also provisions any alerts as real alarm definitions and channels as webhooks.

curl -s -X POST http://localhost:8302/api/simple/deploy \
-H "Authorization: Bearer $TOKEN" | jq
{
"status": "ok",
"task": "SimpleMode",
"programs": [ "POU_SM_...", "POU_SM_..." ],
"stored": { ... },
"alarms": 0,
"channels": 0,
"fingerprints": { "rules": 1734092113, "sequences": { ... } },
"note": "verify with GET /api/simple/status — loaded=true and every fingerprint matched"
}

A failure returns 400 with { "status": "error", "error": "..." }; 503 if the scheduler is unavailable.


Step 4 — check status: GET /api/simple/status

Verification is witness-based, not hope-based: the deployed engine fingerprints its own committed tables every scan, and status reads those values back and compares them to what the active model should produce. It also reports live sequence state and point values — this is the dashboard's data source.

Before any deploy:

{ "deployed": false, "note": "no Simple Mode engine is running — POST /api/simple/deploy first" }

After a healthy deploy:

{
"deployed": true,
"loaded": true,
"healthy": true,
"rules": { "want": 1734092113, "got": 1734092113, "match": true },
"sequences": [
{
"name": "Startup",
"fingerprint": { "want": 55123, "got": 55123, "match": true },
"running": true, "done": false, "timed_out": false,
"step": 1, "step_name": "Run to temp", "seconds_in": 12.4, "total_steps": 3
}
],
"points": { "Cold Room temp": 6.2, "supply pump": true },
"alerts": [], "channels": []
}

healthy: true means every fingerprint matched — the engine is running exactly the model you stored. A mismatch (match: false, healthy: false) means the live engine and the active model have drifted; redeploy.


Alerts & channels (optional surfaces)

A model can also provision real alarms and notifications — no generated ST, the alarm engine watches the point's live tag directly.

"alerts": [
{ "name": "Cold Room over-temp", "point": "Cold Room temp", "hi": 8.0, "hold_s": 5, "priority": 2 }
],
"channels": [
{ "name": "Ops chat", "url": "https://hooks.example.com/x", "format": "slack", "on_clear": true }
]
  • An analog alert takes any of hi / hihi / lo / lolo (each becomes one alarm definition); an on/off alert is a single BOOL alarm active while the point is ON (no limits). priority is 1 (critical) to 4 (info). Cap: 64 alerts.
  • A channel is a webhook the event pipeline delivers alarm events to — asynchronously, retried, dead-lettered, never from the scan path. format is generic | slack | teams | pagerduty; on_clear also sends clears; secret HMAC-signs the payload; rate_per_min caps delivery (0 = uncapped). Cap: 8 channels.

Redeploy is the source of truth: SM-owned alarms/webhooks are recreated each deploy, so a redeploy prunes any the model no longer defines.


Notes & limits

  • No ST, ever. You author points + rules + sequences as data; the runtime generates and validates the ST for you. The generated POUs are real and inspectable in Engineer Mode, but you never hand-edit them — edit the model and redeploy.
  • The flow is PUT → compile → deploy → status. compile is a side-effect-free dry run; deploy pushes live; status proves it loaded via fingerprint witnesses.
  • Conditions read in points, actions drive out points. The validator rejects a condition on an output or an action on an input with a readable message.
  • after_time, ramp, and hold are sequence-only — they need a step's time base and are rejected inside a rule.
  • Capacity caps are enforced at validate time: 32 points/bank, 32 rules, 4 sequences, 32 steps/sequence, 4 actions per rule or step, 64 alerts, 8 channels. Exceeding any fails the PUT.
  • A redeploy resets SM-owned alarm/channel state. Ack/shelve state of a Simple Mode alarm does not survive a deploy — the model is the source of truth.
  • The SimpleMode task runs at 100 ms by default (or the task's configured scan time if it already exists); the engine bakes that period into its time base, so for_s, after_time, and ramp durations are honored against the real scan clock.