Recipes
A recipe is a set of values that go out to the plant together. Grade A’s zone temperatures, line speed and die gap. The changeover to the 500 ml bottle. The night setback. Every plant has these, and every plant has a laminated sheet somewhere with the numbers on it and an operator typing them in one at a time, and every plant has the story about the night one of them got typed in wrong.
So the numbers live here instead, and they go out in one go: checked whole
first, sent in order, read back from the device afterwards, every one of them
on the record. Every recipe is also an
entity, scene.<id>. It’s built for equipment that can hurt somebody,
which is why most of this page is about what happens when something goes
wrong.
What one looks like
Section titled “What one looks like”{ "id": "grade_a", "name": "Grade A", "description": "The 2pm run. Die at 210, line at whatever the order says.", "equipment_id": 7, "apply_permission": "recipes.apply", "params": [ {"name": "speed", "label": "Line speed", "type": "number", "min": 0, "max": 400, "step": 10, "unit": "rpm", "required": true} ], "precondition": "states['input_boolean.line_ready'] == 'on'", "targets": [ {"entity_id": "loop.line_02_die_tic", "service": "set_mode", "data": {"mode": "auto"}}, {"entity_id": "loop.line_02_die_tic", "service": "set_setpoint", "data": {"value": 210}}, {"entity_id": "number.line_02_speed", "service": "set_value", "data_expressions": {"value": "vars.speed"}}, {"entity_id": "switch.line_02_heater", "service": "turn_on"}, {"entity_id": "input_select.product", "service": "select_option", "data": {"option": "Grade A"}} ], "verify_sec": 10}idis lowercase letters, digits and underscores, starting with a letter. It is its entity’s object id,scene.grade_a, and never changes.equipment_idis the node of the equipment tree the recipe belongs to, for finding it. The Recipes screen groups by it. Leave it out for a recipe of the whole plant.paramsare what an application takes: the same typed fields a script has (model.Field:numberwithmin,max,stepandunit,text,boolean,select,entity,duration,alarm,tag). A target reads one asvars.<name>in adata_expressionsentry.preconditionis an expression in the automation language that must be true for the recipe to start. Leave it out and there is none.targetsare the values, in the order they go out. Each is an entity, the service that sets it, and that service’s data, as a value (data) or computed from the parameters (data_expressions). Up to 200.verify_secis how long each written value is given to show up in a reading from the device. 1 to 600, default 10.
What a target can be
Section titled “What a target can be”A recipe sets values. The services it takes are the ones that leave equipment holding a value:
| Domain | Services |
|---|---|
switch, input_boolean |
turn_on, turn_off |
number, input_number, input_counter |
set_value |
text, input_text |
set_value |
select, input_select |
select_option |
valve |
set_position |
motor |
set_speed |
loop |
set_setpoint, set_mode |
A button press, a motor start, a counter reset, a valve stop: refused when you save. Those are actions, not values, and they belong in a script. “Apply grade A” pressing a reset button every time somebody applies it is not what anybody means by a recipe, and the day it happens during a run is the day you find out. Toggle is refused too: a recipe that flips a switch does the opposite thing every other time.
A target’s entity has to exist when you save, its expressions have to compile, and the same entity cannot be set twice by the same service (two values for one setting, and which one wins would depend on an order nobody reads). A loop’s mode and its setpoint are two settings and both can be set; set the mode first, because a setpoint for auto means nothing to a loop left in manual.
Applying one
Section titled “Applying one”From the Recipes screen, the recipe’s tile on the Entities screen, a rule’s or
a script’s apply_recipe step, MCP’s apply_recipe, or
POST /api/services/scene/turn_on. Every one of them does exactly this:
1. Checked whole, refused whole
Section titled “1. Checked whole, refused whole”Before a single value goes anywhere:
- The parameters are checked against their fields. A value is never moved
to fit. 450 on a field whose maximum is 400 is refused, not clamped to
400.
"250"for a number is refused, not parsed. 255 on a step of 10 is refused, not rounded. The number that goes to the plant is exactly the one somebody entered, or nothing goes. - The recipe’s own permission, when it names one (below), and the MCP write switches, for an agent.
- The precondition is evaluated against the plant as it is now.
- Every data expression is evaluated. One that fails refuses the recipe; a target is never sent without the value it was meant to have.
- Nothing else in this process may be writing to one of its entities (below).
- Every target is put to the write gate’s own checks, the same ones its
write will meet: writes on, the tag writable, the value in the tag’s range,
the entity enabled and happy with the value (its step, its options, a loop
in manual for its output), the device connected.
Services.Check, which for a tag isruntime.Manager.CheckWrite. Nothing is written or audited.
If anything fails, the whole recipe is refused and nothing is written.
Not the first ten targets and then a refusal. Nothing. And you get every
reason, not just the first: an operator told one problem fixes it and is told
the next, and a recipe of forty targets should be fixed in one go. Each target
says which it was: would_write, refused with the gate’s words, or
not_sent.
2. Written in order, one call id, never rolled back
Section titled “2. Written in order, one call id, never rolled back”Then the targets go out in order, each through Services.Call and so through
runtime.Manager.Write, the one write gate. There is no side door. Every
write carries the application’s call id, so the whole application is
GET /api/writes?call=<id>, in the audit trail, next to every other write
the plant ever took.
A check can pass and the write still fail a moment later. A PLC answers a
write with an exception. A cable gets kicked. Somebody disarms a tag between
the check and the write. The first target that does not go through stops
the application. The ones after it are not sent, and say skipped.
Nothing already written is undone. This is deliberate, and it is the rule every HotLoop write follows. Undoing is itself a write: made without anybody asking for it, to equipment that has already moved, based on what we think the value was before, at the exact moment something has just proved the plant is not behaving the way we expected. That is how a half-applied recipe becomes a fully confused line. Deciding what to do about a half-applied recipe is the operator’s job, and they get exactly the information to do it with:
PARTIAL v3: 2 of 5 written before one failed, nothing undone 1. set_mode loop.line_02_die_tic: written, verified 2. set_setpoint loop.line_02_die_tic: written, verified 3. set_value number.line_02_speed: failed: the device answered the write with an exception 4. turn_on switch.line_02_heater: skipped 5. select_option input_select.product: skippedA target is written, failed (it went out and the device said no, or said
nothing, so whether it took is not known), not_sent (the gate turned it away
at the last moment), or skipped. The application is applied (every target
written), partial (some were), or failed (the first one was not).
The Recipes screen then offers Apply the remaining targets. That is a new
application, of the targets that were not written, with the same version and
parameters, checked whole again first, audited under a call id of its own,
with application:<id> as its parent so the two are found together. It is
never automatic. Somebody looks at what happened, fixes whatever stopped it,
and presses the button.
3. Read back
Section titled “3. Read back”Written is not the same as taken. A PLC can accept a write and its own logic overwrite it on the next scan. A scaled register can take 12.34 and hold 12.3. So after the last write, each written value is read back:
- for a target on a device, only from a reading taken after its write, so the cache remembering what the device said before cannot confirm anything;
- from the entity’s state, or for a valve, a motor and a loop, the attribute
the service sets (
current_position,speed,setpoint,mode); - a number within a millionth of itself (a float register gives 0.1 back as 0.10000000149, which is the same setpoint), anything else exactly.
Each written target is verified once it reads back, or unconfirmed
if it has not within verify_sec, with what was read instead:
3. set_value number.line_02_speed: written, unconfirmed (not confirmed within 10s: read back 240, not 250)Unconfirmed is shown amber, never green, on the Recipes screen, and it is
a warning in the logbook and the system log. It is not a failure: the value went out and the
device accepted it, and the application is still applied. It is a flag that
says go and look, because it may not have taken. A helper holds its own value,
so a helper target is verified straight from it.
The answer comes back once the read-back is done, so applying a recipe takes
up to verify_sec.
Overlap
Section titled “Overlap”Two recipes interleaving their writes to the same setpoint leave it at
whichever landed last, which is nobody’s recipe. So while an application is
writing and reading back, it holds every entity it targets, and another
application that targets any of them is refused, 409, with which recipe is
in the way. Wait for it, then apply.
This is held in the process: an application from the Gateway and one from an
--mcp-stdio process beside it are not held off each other. Run one writer,
or keep the agent’s recipes and the operators’ on different equipment.
Versions
Section titled “Versions”A recipe’s name, description, equipment node and permission are the recipe’s
and change in place. What it writes is a version, and a version is never
changed. Saving different targets, parameters, a different precondition or
verify_sec stores the next version and makes it the current one. Version 3
is exactly what it was the day it was saved, forever, and every application
records the version and the parameters it ran with. What went out to the plant
last Tuesday is always answerable.
- Changing only the name, or only the note, makes no version.
- A save made to a version somebody else has since replaced is refused,
409, rather than quietly throwing their change away. The editor sends the version it was editing. - An older version can be applied on purpose: pick it in the apply drawer, or
"version": 2. The application says it was version 2. - The Versions drawer shows every version with who saved it, their note, and what changed from the one before it, target by target.
Deleting a recipe deletes its versions. Its applications are kept: they are what was sent to the plant, and that history outlives the recipe.
Capturing one
Section titled “Capturing one”Nobody writes a forty-target recipe by hand when the line is already running grade A. Capture takes the entities you pick and drafts a recipe that sets each back to what it holds right now, in the order you picked them: a number’s value, a switch’s on or off, a select’s option, a valve’s position, a motor’s speed, a loop’s mode and then its setpoint. You name it, trim it, turn the numbers that change per order into parameters, and save.
An entity whose value is not known, or not trusted, is refused, not
captured: a reading of quality other than good, unknown, unavailable, a
disabled entity. A recipe built from a bad reading writes the bad reading back
to the plant, every time somebody applies it.
Who may apply one
Section titled “Who may apply one”Operators apply recipes (recipes.apply); admins write them
(recipes.write); everybody reads them (recipes.read). Loading grade A is
what the operator is there to do. Deciding what grade A is, is an engineer’s
call.
A recipe can name a narrower permission of its own:
"apply_permission": "recipes.apply.night_setback". Then applying it takes
that as well, which the operator role does not hold. Give it to the people who
may apply that recipe through their own permission list:
["recipes.read", "recipes.apply", "recipes.apply.night_setback"]recipes.apply.* grants every recipe’s, and an admin holds everything. A user
without it is refused, and the refusal is on the record: a refused
application in the recipe’s history, with their name on it, like any other.
The same goes through the scene entity.
An agent holds no user’s permissions, so an agent never applies a recipe that names its own permission. It can read it and ask whether it would apply; the answer says why not.
The precondition
Section titled “The precondition”"precondition": "states['input_boolean.line_ready'] == 'on' and float(states['sensor.line_02_die_temp']) < 250"The automation language, evaluated against the plant as it is when somebody
presses Apply, with the parameters as vars. False refuses the recipe,
saying so. It is a guard against applying the right numbers at the wrong
moment, not an interlock: an interlock lives in the PLC, where it still works
when the network does not.
From a rule, a script or an agent
Section titled “From a rule, a script or an agent”A rule or a script applies one with an apply_recipe step (see
AUTOMATIONS.md):
{"type": "apply_recipe", "params": {"recipeId": "grade_a", "params": {"speed": 250}}}It applies it as the rule, automation:<id>, or the script, script:<id>,
with that run’s source on every write, and the application’s parent is the
run. The step fails unless every target was written; an unconfirmed value does
not fail it, and the trace says NOT confirmed by read-back. A script’s dry
run checks the recipe too.
Over MCP, list_recipes shows every recipe with its targets and parameters,
validate_recipe asks whether one would apply right now, and apply_recipe
applies it, with a required reason. An agent’s writes carry the source
mcp, so the MCP write switch applies to every one of them in the gate, and
with HOTLOOP_ALLOW_MCP_WRITES off apply_recipe is refused before anything
is checked. See MCP.md.
Its entity
Section titled “Its entity”Every recipe is a scene entity, scene.<id>. Its state is when it was last
applied (an application that wrote something), as a timestamp, and unknown
until it ever has been. Its attributes say how the newest application went:
last_result, last_version, last_applied_by, last_application, and
unconfirmed when that many values of an applied one were not read back. Its
tile has an Apply button. scene.turn_on applies it, with params and
version in the data. A rule can trigger on its state, which moves once per
application that wrote something.
Deleting a recipe orphans its entity, the way deleting a script does.
The record
Section titled “The record”Every application, refused ones included, is a row of recipe_applications:
the recipe, the version, the parameters, who, from where, why, the call id,
what became of every target and whether it was read back. It is opened before
the first write and finished after the read-back, so one the process dies in
the middle of still says it started. The Gateway’s next start marks it
failed, interrupted by restart, pointing at its call id in the write
audit. Nothing resumes it.
Each application is one line in the system log under the scene, and one entry
in the logbook, kind recipe, next to the writes it made. A
recipe’s history keeps its newest 5000 applications.
More than one process
Section titled “More than one process”Every change to a recipe and every application is announced to every process
on the database, so a recipe saved on one pod is the one the other applies,
and an application an --mcp-stdio process makes moves the Gateway’s scene
entity.
Backups
Section titled “Backups”Recipes, every version, and every application are in backups, and come back
exactly as they were. An application the backup caught in the middle comes
back applying and is marked failed on the first start, like a crash. See
BACKUP.md.
The API
Section titled “The API”API.md has every route. In short:
| Details | Details |
|---|---|
GET /api/recipes |
every recipe, its current content and last application |
POST /api/recipes |
create one, as version 1 |
POST /api/recipes/capture |
a draft from what entities hold now |
PUT /api/recipes/{id} |
save; new content is a new version |
GET /api/recipes/{id}/versions |
every version, as saved |
POST /api/recipes/{id}/validate |
would it apply now? nothing written |
POST /api/recipes/{id}/apply |
apply it: 200 applied, 409 refused, 502 partial or failed |
GET /api/recipes/{id}/applications |
every application, refusals included |