Skip to content
GitHub

Helpers

A helper is a value the plant’s people own rather than a PLC. “Line 2 is in changeover.” “Today’s batch target is 1,250 kg.” “B shift is on.” “Twelve parts scrapped since the last reset.” “The purge has nine minutes left.” None of that lives in a register, and all of it is exactly what rules and screens need to know.

HotLoop has seven kinds of them. A helper is an entity like any other: it has an entity_id, a state, attributes and services, it sits on the equipment tree, it shows up in /api/states, on the Entities screen, in list_entities over MCP, and in an automation’s state trigger. What is different is where its value lives: HotLoop keeps it, in the database, and no service of a helper ever writes a tag.

Domain State Options Services
input_boolean on / off initial (true/false), restore turn_on, turn_off, toggle
input_number the number, "1250" min, max (both required), step (default 1), mode (slider or box), initial, restore, and a unit_of_measurement set_value {value}, increment, decrement
input_select the choice choices (required, up to 100), initial, restore select_option {option}, select_next {cycle}, select_previous {cycle}
input_text the text max_length (default and ceiling 255), pattern, initial, restore set_value {value}
input_counter a whole number initial (default 0), step (default 1), min, max, restore increment, decrement, reset, set_value {value}
timer idle / active / paused duration_sec (required, 1 second to 7 days), restore start {duration}, pause, cancel, finish
schedule on / off schedule (required) none: it follows the clock

The counter is input_counter, not counter. counter is already the PLC composite whose reset writes a tag, and it stays exactly what it is.

Every option is checked when the helper is saved, and initial against all the others: an input_number from 0 to 100 that starts at 150 is refused, not started at 100. An option a domain does not take is an error, not a setting quietly ignored. The helper options (initial, restore, choices, duration_sec, mode, schedule) are refused on every other domain for the same reason.

An admin makes helpers (entities.write). On the Entities screen, New helper, next to New equipment. Over the API:

POST /api/helpers
{"name": "Batch target", "domain": "input_number", "unit_of_measurement": "kg",
"options": {"min": 0, "max": 5000, "step": 50, "initial": 1000}}

The entity_id is made from the name (input_number.batch_target) unless you give one. PUT /api/helpers/{entity_id} changes its name, icon, unit or options; DELETE /api/helpers/{entity_id} deletes it and its value. The whole API is in API.md.

A change of options that would leave the helper’s current value outside them is refused: narrowing a range past the value, or removing the choice that is selected. Set the value first, or choose options that include it. A value is never moved to fit.

Anybody with helpers.write sets helpers. Operators hold it: running the shift selector and counting scrap is operation, not configuration. Viewers do not. It is not values.write, because a helper writes no tag.

Setting a helper is an entity service call, the same call every other entity takes:

POST /api/services/input_counter/increment {"entity_id": "input_counter.scrap", "reason": "short shot"}
POST /api/services/timer/start {"entity_id": "timer.purge", "duration": "0:15:00"}

The answer says what the helper is now: {"call_id": "...", "state": "13", "writes": []}. ?dry_run=true works too and answers in the same words the real call would, without changing anything.

On the Entities screen a helper gets its own tile, never folded into another (it has no tags to fold by), with its controls for whoever holds helpers.write. Every change is confirmed first, with a reason, like every other command.

From MCP there is no new tool: list_entities finds helpers, get_states reads them, call_service sets them. An agent changes a helper only on a deployment with HOTLOOP_ALLOW_WRITES and HOTLOOP_ALLOW_MCP_WRITES both on, the same switches a write to a tag needs. A helper moves no equipment, but it is what rules act on, and an agent flipping the changeover switch is an agent starting whatever the changeover rule does.

A value a helper will not take is refused, and nothing changes:

  • a number outside min..max, or off its step (counted from min);
  • an increment that would pass max, a decrement that would pass min (refused, not stopped at the limit: “stopped at the limit” is a value nobody asked for);
  • an option that is not one of the choices; select_next past the last choice with cycle: false;
  • a text longer than max_length, or not matching pattern (the whole text must match);
  • a count that is not a whole number, or past the counter’s min or max;
  • a timer paused when it is not running, cancelled or finished when it is idle, or started for less than a second or more than seven days;
  • a disabled helper; a caller without helpers.write; an agent with MCP writes off.

Every refusal is a warning in the system log (GET /api/events) against the helper, with who asked and why it was refused, exactly like a refused alarm acknowledgement. A mistake in the request itself (a field the service does not take, "50" where a number goes) is the caller’s error, answered 400, and changes and records nothing.

Every change is one line in the system log against the helper: who, from what, to what, the call id, where it came from, and the reason if one was given.

otto input_counter.increment: 12 -> 13; reason: short shot
system timer finished: active -> idle, due at 2026-09-27T21:10:00Z

The line is written in the same transaction as the value, so there is never a change without its line or a line without its change.

A change of state is also a line in the logbook, in that same transaction, with the same who, source and call id. It is written by the process that made the change, whichever one that is, so an agent’s change through --mcp-stdio is logged too, and logged once. Helper lines are never rate-limited: every one is somebody deciding something. A call that leaves the state as it was (a number set to the value it already has) is in the system log but not the logbook, because nothing changed.

A helper keeps its value across a restart: the Gateway comes back with the shift still B and the counter still 13. Set restore: false on one that should start fresh every time (a “since the last restart” counter): the Gateway puts it back to its initial value as it starts, and says so in the log. Only the Gateway itself does that. An --mcp-stdio process an agent starts beside a running Gateway holds the same helpers and hears every change, but never resets anything, because resetting the plant’s values because an agent connected would be absurd.

A timer runs on the wall clock. Started for ten minutes, it finishes ten minutes later whatever happens in between: a restart that took two minutes leaves it eight to run. A paused timer stays paused, with the time it had left, across any number of restarts, and start resumes it.

A timer that ran out while no HotLoop was running is put back to idle when HotLoop next starts, with finished_late_at set to when it was due, and a warning in the log. Nothing fires for it. A rule waiting for that timer to go idle does not run, because the engine’s first look at it after a start is a baseline, not a change. Whatever it was timing (a purge, a cure) happened long ago or not at all, and doing it now, late and unasked, is worse than saying it was missed. Look for finished_late_at and decide.

A timer is not an interlock. It runs on a process’s clock, through a database and a network, to the second and usually the millisecond. Anything that must stop on time belongs in the PLC.

A schedule is on inside its blocks and off outside them:

{"schedule": {
"mon": [{"from": "06:00", "to": "14:00", "data": {"crew": "A"}},
{"from": "14:00", "to": "22:00", "data": {"crew": "B"}}],
"fri": [{"from": "22:00", "to": "06:00", "data": {"crew": "C"}}]
}}
  • The days are mon to sun. A time is HH:MM or HH:MM:SS; 24:00 is the end of a day.
  • A block whose to is earlier than its from runs past midnight: Friday’s 22:00 to 06:00 is on until Saturday 06:00.
  • Blocks that touch are one stretch of on; blocks that overlap are refused, because which one’s data applies would be a guess.
  • While a block is on, its data is in the entity’s attributes (crew: "B"), and next_event always says when the schedule next changes.
  • It is read on the wall clock in the site’s timezone (HOTLOOP_SITE_TIMEZONE), because a shift that starts at 06:00 starts at 06:00 on the day the clocks change too. On the night the clocks go forward, the hour that does not happen is not on; on the night they go back, the hour that happens twice is on both times.

Every change is announced with Postgres NOTIFY, and every process holding the helpers listens. A helper set in one process shows its new value in every other within a round trip, a helper created or deleted in one appears or goes in the others, and a change made through PUT /api/helpers/{entity_id} is picked up the same way (one made through PUT /api/entities/{entity_id} reaches the others on their next registry sync, as any entity’s does). Every write is conditional on the row’s revision, so two processes counting the same counter at once never lose a count, and a timer both hold is finished by exactly one of them.

Helpers are in every backup: their configuration with the entities, and their values in helper_state, revisions included. A restored plant comes back with the shift, the targets and the counts it had, and a timer that was running is still running on the wall clock (or, if it ran out in the meantime, idle and marked late, as above).

A helper is what rules act on and set:

{"triggers": [{"type": "state", "entityId": "input_boolean.changeover", "to": "on"}],
"actions": [{"type": "call_service", "params": {"entityId": "number.line_2_speed_sp",
"service": "number.set_value", "data": {"value": 0}}}]}

The rule’s write to the tag goes through the write gate like any other, with the rule as the actor. states['input_select.shift'] == 'B' works in a condition, and a rule can set a helper with call_service the same way it sets a switch.