Blueprints
A blueprint is a rule written once with blanks in it. “When this motor has been running longer than this long, say so, and stop it if I ask you to.” Somebody who knows what the rule should do writes it one time. Everybody else fills in the blanks, per pump, per tank, per line, and gets an ordinary automation out the other end.
The alternative is what every plant already has: the same rule copied onto forty pumps, then fixed on thirty-seven of them the day somebody finds the bug. Copies drift. A blueprint and forty sets of inputs don’t.
What comes out is a normal rule. It sits on the Automations screen next to
the ones written by hand, it is armed by the same engine, and every write it
makes goes through the write gate as automation:<id>, audited, refusals
included. There is no blueprint runtime and no side door. There’s a form, and
there’s a rule.
The one rule about blueprints
Section titled “The one rule about blueprints”A rule keeps the version of the blueprint it was made from.
Edit a blueprint and its version goes up. Not one rule made from it changes. The rule on pump 7 that was right on Tuesday does not quietly become a different rule because somebody edited its blueprint on Wednesday, because the person who edited it was thinking about pump 12, and pump 7 is the one that floods the basement.
Moving a rule to the new version is an upgrade, and an upgrade is something a person asks for:
- The Automations screen says so on the rule: From blueprint Motor running too long, version 1, and version 2 available.
- Review upgrade shows exactly what changes, part by part, before and after, and what the rule would do if it fired right now, every write put to the gate’s checks. Nothing has changed yet.
- Upgrade puts in exactly what was reviewed. If the blueprint moved on again, or somebody else upgraded the rule, in between, it is refused and you look again. What you reviewed is what goes in, or nothing does.
It is logged with who did it: automation upgraded from blueprint pump_watch version 1 to 2.
If somebody edited the rule by hand since it was made, the review shows that edit as being replaced. Nobody’s change disappears without being on the screen first.
What one looks like
Section titled “What one looks like”{ "id": "pump_watch", "name": "Pump start watch", "description": "Says when a pump starts, with the limit it runs to.", "inputs": [ {"name": "pump", "label": "Pump", "type": "entity", "domain": ["switch"], "required": true}, {"name": "limit", "label": "Limit", "type": "number", "min": 0, "max": 100, "step": 5, "unit": "%", "required": true} ], "template": { "triggers": [{"type": "state", "entityId": {"!input": "pump"}, "to": "on"}], "condition": "float(states['sensor.tank_level']) < ${limit}", "actions": [ {"type": "log_event", "params": {"message": "${pump} started, running to ${limit}%"}} ], "mode": "skip" }}idis lowercase letters, digits and underscores, starting with a letter.inputsare the blanks, typed. Same fields as everywhere else in HotLoop:numberwithmin,max,stepandunit,textwithmax_lengthandpattern,boolean,selectwithoptions,entitywith thedomains it takes,duration(seconds, or90s,1h30m),alarmandtag. Each can berequiredor have adefault.templateis the rule:description,triggers,condition,actions,mode,minIntervalSec,maxRuntimeSec, in the automation language (AUTOMATIONS.md). Not itsid,nameorenabled. Those belong to each rule made from it.
The blanks
Section titled “The blanks”{"!input": "pump"} as a value is replaced by the input’s value, with
its type: an entity_id lands in entityId as a string, a duration lands in
forSec as a number. An optional input nobody filled in is null.
${pump} inside a string is replaced too, and how depends on where:
- In an expression (a rule’s
condition, an action’sexpression, a value indataExpressions,argExpressions,fieldExpressionsorparamExpressions, and anything between{{and}}in a message) it goes in as a literal. A string is quoted, a number is a number,true,false,nil. So the condition above, with a limit of 40, isfloat(states['sensor.tank_level']) < 40. - Anywhere else it is plain text:
switch.pump_1 started, running to 40%.
${counter.domain} is an entity input’s domain, for a service name that
has to match whichever kind of entity got picked: "${counter.domain}.reset"
is counter.reset for a PLC’s counter and input_counter.reset for a
counter helper.
That quoting is the whole reason a text input is safe to use in an
expression. Type ' || true || ' into a text input and the condition
compares against that string, exactly as typed. It never becomes part of the
expression. And a text input can’t contain {{ at all: every
message a rule sends runs what’s between {{ and }} as an expression when
it’s sent, and a value somebody typed into a form never gets to become one.
Checked when it is saved
Section titled “Checked when it is saved”A blueprint is checked before it is stored, not when somebody tries it:
- every blank names an input, and
.domainonly follows an entity input; - every input is used somewhere. An input the template ignores is a form field that does nothing, and somebody will lose an afternoon to it;
- filled in with sample values of each input’s type (its default when it has
one), the template makes a rule that compiles. A text input that becomes a
URL needs a
patternsaying so,https?://\S+, or the URL check refuses the sample.
Filling one in
Section titled “Filling one in”On the Blueprints screen, Use opens the form: the rule’s id and name, and each input as the right control, a number with its range, a list, an entity picker. Then:
- Check first answers the rule it would make and what that rule would do
if it fired right now: the condition evaluated, every write and service call
put to the write gate’s own checks as
automation:<id>, the first refusal in the gate’s words. Nothing is stored, written or audited. - Make the rule makes it, armed unless you untick that, and it shows up on the Automations screen saying where it came from.
Every input is checked first and never moved to fit. 150 for an input
whose maximum is 100 is refused, not quietly made 100. "12" for a number is
refused, not parsed. Every input that’s wrong is reported at once, so the form
comes back with the whole list. A value the rule itself refuses (a duration of
two days for a hold that can be one day at most) is refused in the rule’s own
words. Nothing is made until all of it is right.
Making a rule never replaces one. An id that is taken, by a rule written by hand or one made from a blueprint, is refused.
Over the API it is POST /api/blueprints/{id}/instantiate, with
?dry_run=true for the check. See API.md.
The built-in ones
Section titled “The built-in ones”These ship in the binary. They are read-only; Copy makes one of your own under a new id, starting at version 1, to change as you like.
| Blueprint | Inputs | What the rule does |
|---|---|---|
motor_running_too_long |
a motor, how long it may run (default an hour), whether to stop it | When the motor has been running that long in one go, logs a warning. If asked, stops it with motor.stop. |
tank_high_level_stop_pump |
a level sensor, the limit, the pump (a motor) | When the level is at or over the limit, and the reading’s quality is good, and the pump is not already stopped, stops the pump and logs it. |
alarm_to_notify |
an alarm, the state to act on (default unack-active), a webhook URL |
When the alarm goes to that state, POSTs {"text": "Alarm temp-hi is unack-active"} to the URL. |
shift_schedule_counter_reset |
a schedule helper, a counter (PLC or helper) | When the schedule turns on, resets the counter and logs it. |
Some honest notes on them:
tank_high_level_stop_pumpis a convenience, not an interlock. A trip that protects people or the plant belongs in the PLC, hard-wired where it has to be, not in anything that runs on a network. This one saves somebody from watching a level. It does not replace the high-level switch.- It won’t act on a reading it can’t trust. A level with bad quality stops nothing, because a stop based on a garbage number is still a stop.
shift_schedule_counter_resetfires when the schedule turns on, and two schedule blocks that touch are one stretch of on. So give each shift its own block with the schedule off between them, even for a minute (06:00 to 13:59, 14:00 to 21:59), or only the first shift of the run resets the counter.alarm_to_notifyis for a chat room or a ticketing system. For paging a phone, with rate limits, retries and acknowledge links, use the notifications HotLoop sends itself (NOTIFY.md).
Who can do what
Section titled “Who can do what”| Details | viewer | operator | admin |
|---|---|---|---|
See blueprints (blueprints.read) |
yes | yes | yes |
Make a rule from one, upgrade one (blueprints.apply) |
no | yes | yes |
Write, edit, copy, delete one (blueprints.write) |
no | no | yes |
Why operators can make rules: the blueprint is where an engineer decided what
the rule does, and picking which pump it watches is exactly the call an
operator makes all day. The dangerous part, what the rule commands, was
decided when the blueprint was written, by somebody holding
blueprints.write. Deleting or hand-editing the rule afterwards is still
automations.write, admin.
Deleting a blueprint that any rule was made from is refused, naming the rules. Delete those first. A built-in blueprint can’t be edited or deleted.
Agents
Section titled “Agents”An agent gets list_blueprints and preview_blueprint: it can find the
right blueprint, work out the inputs, and show what the rule would do. It
can’t make the rule. That’s on purpose. A rule is a standing order to command
equipment every time its trigger fires, for months after the conversation
that suggested it is over, and whether a plant has one is a person’s call.
See MCP.md.
Where it all goes
Section titled “Where it all goes”- Blueprints the plant wrote are in the
blueprintstable. The built-in ones are in the binary and never stored. - A rule made from one is a row in
automationslike any other, withblueprint_id,blueprint_versionandblueprint_inputssaying where it came from.GET /api/automationshas them asblueprintId,blueprintVersionandblueprintInputs. - Every change is in the system log and the logbook, with who made it: blueprint created, blueprint saved, now version 2, automation made from blueprint motor_running_too_long version 1, automation upgraded from blueprint pump_watch version 1 to 2, blueprint deleted.
- Backups carry both the blueprints and every rule’s pin, so a restored plant offers exactly the upgrades it offered before. A backup from before blueprints existed restores every rule as written by hand.
- A rule whose blueprint is gone (a built-in one a later release dropped, say) keeps running exactly as it is. There’s just nothing to upgrade it to.