Skip to content
GitHub

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.

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:

  1. The Automations screen says so on the rule: From blueprint Motor running too long, version 1, and version 2 available.
  2. 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.
  3. 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.

{
"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"
}
}
  • id is lowercase letters, digits and underscores, starting with a letter.
  • inputs are the blanks, typed. Same fields as everywhere else in HotLoop: number with min, max, step and unit, text with max_length and pattern, boolean, select with options, entity with the domains it takes, duration (seconds, or 90s, 1h30m), alarm and tag. Each can be required or have a default.
  • template is the rule: description, triggers, condition, actions, mode, minIntervalSec, maxRuntimeSec, in the automation language (AUTOMATIONS.md). Not its id, name or enabled. Those belong to each rule made from it.

{"!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’s expression, a value in dataExpressions, argExpressions, fieldExpressions or paramExpressions, 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, is float(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.

A blueprint is checked before it is stored, not when somebody tries it:

  • every blank names an input, and .domain only 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 pattern saying so, https?://\S+, or the URL check refuses the sample.

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.

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_pump is 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_reset fires 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_notify is 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).
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.

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.

  • Blueprints the plant wrote are in the blueprints table. The built-in ones are in the binary and never stored.
  • A rule made from one is a row in automations like any other, with blueprint_id, blueprint_version and blueprint_inputs saying where it came from. GET /api/automations has them as blueprintId, blueprintVersion and blueprintInputs.
  • 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.