Skip to content
GitHub

Entities

internal/entity is the entity registry and the engine that computes entity state. It is the first piece of Phase 1 in ROADMAP.md: the foundation every automation, helper, script and screen in HotLoop is built on.

A tag is what a driver talks about: a holding register, a NodeId, an S7 DB offset. It is keyed by device and tag name, it cannot be renamed, and it means exactly one point on one wire.

An entity is what people and rules talk about: “the zone 1 temperature”, “the conveyor run switch”. It has a stable name of its own (sensor.zone_1_temperature), a type that says how to show it and what you can do to it, and a set of bindings to the tags it is made of.

Entities wrap tags. They do not replace them, and nothing about tags changed to make room for them. There is no second value pipeline and no second historian: an entity’s state is computed from the same live cache that /api/values, the alarm engine and the OPC UA server already read. Every existing tag endpoint, the board, /api/write, the MCP tools and automation rules that existed before entities behave exactly as they did; entities are added next to them, not in place of them.

Every tag gets a read entity, automatically:

Tag data type Read entity domain
bool, boolean, bit, coil, discrete binary_sensor
everything else sensor

The first time a tag is made writable it also gets a control entity:

Tag data type Control entity domain
bool family switch
int and float families number
string, text, char text

A control entity is never removed because its tag was disarmed. The write gate refuses writes to a disarmed tag the way it always has, and the entity’s armed attribute goes false so a screen can gray the control out. Keeping the entity is the point: an automation or a dashboard that names switch.press_01_pump should not break because somebody disarmed the pump for a shift.

Existing tags get their entities in the upgrade itself: migration 0005_entities.sql backfills a read entity for every tag and a control entity for every tag that is already writable, in the same transaction that creates the tables. Either every tag has its entity when the new version serves its first request, or nothing changed.

Every alarm definition gets an alarm entity too, the same way: see Alarm entities.

Commanding a control entity (turning a switch on, setting a number) is a service call, and a service call is tag writes through runtime.Manager.Write and the write gate like every other write. There is no other door. See Services.

Equipment that is more than one tag (a pump with a run command, a running contact and a fault; a valve with limit switches; a PID loop) is a composite you build on purpose out of the tags it is made of. See Composite equipment.

An entity_id is <domain>.<object_id>: lowercase letters, digits and underscores, at most 255 characters. The automatic one for a tag is <domain>.<device>_<tag>, where both halves are lowercased and every run of anything that is not an ASCII letter or digit becomes one underscore:

Device Tag Read entity
press-01 Zone1_Temp sensor.press_01_zone1_temp
Line.A-2 Motor/Speed.PV sensor.line_a_2_motor_speed_pv
press-01 温度 sensor.press_01_tag

Two tags that come out the same get _2, _3 and so on, in the order they were registered. A name too long for 255 characters is cut before the suffix, never through it.

An entity_id is generated once, stored, and never regenerated. Renaming a tag’s description, changing its data type, moving it to a different register: none of that touches the entity_id. The only thing that changes it is an explicit rename through PUT /api/entities/{entity_id}, which is an admin action and lands in the system log (category config, subject the new id, the old id in the message and the detail).

A rename keeps the domain. An entity’s identity is its database id and its unique_id, and neither ever moves:

Entity unique_id
a tag’s read entity tag:<device>/<tag>
a tag’s control entity tag-control:<device>/<tag>
an alarm entity alarm:<definition id>
a composite composite:<32 random hex digits>

The first three are derived from what the entity wraps, which is what lets a deleted tag or alarm definition that comes back re-adopt its entity. A composite has nothing like that to derive one from: two identical pumps on one PLC are bound to different tags, and the same tags can back two composites on purpose. So its unique_id is random, made once when it is created. It can never collide with anything else’s, including a composite that was deleted and built again, which is a new entity and should be.

Delete a tag and its entities are orphaned, not deleted: orphaned_at is set and the state goes unavailable. Bring the same tag back, by the tag editor or by a browse re-import, and the registry finds the orphan by unique_id and re-adopts it. Its entity_id, name, device class, icon and every other customisation come back with it.

That is deliberate. On a plant floor a tag gets deleted and re-imported far more often than anybody means “forget everything about this point”, and an automation naming sensor.oven_temperature should keep working across it.

Deleting a whole device is different: its entities go with it, the same way its tags do. That includes a composite whose tags are all on that device. A composite spanning several devices belongs to none of them, and loses nothing but its state when one goes.

Alarm entities follow their alarm definitions by the same rules: delete the definition and its entity is orphaned, save a definition with the same id and it is re-adopted.

A composite is never orphaned. It was built on purpose, so it stays until somebody deletes it; while one of its tags is gone it is unavailable.

DELETE /api/entities/{entity_id} deletes a composite, or an orphan. It refuses (409) a tag’s or an alarm definition’s entity that still has what it wraps, and says how it goes away: delete the tag or the definition, then the orphan. Deleting a live one would only have the next sync put it back under a new id, and break everything naming it in between. To take one off screens, hide or disable it.

The registry does not hook each place a tag can change. It reconciles: read every tag and every entity, create what is missing, orphan what lost its tag, re-adopt what got it back. A reconcile cannot miss a path somebody adds next year, and running it twice is running it once. It runs:

  • on start, which covers an upgrade and a restore from backup;
  • after every runtime reload, which every tag and device change already triggers;
  • synchronously in the API handlers that change tags (POST /api/tags, DELETE /api/tags/..., POST /api/devices/{id}/import, DELETE /api/devices/{id}) and alarm definitions (POST /api/alarms, DELETE /api/alarms/{id}), so a client that creates a tag or a definition and immediately asks for its entity finds it.

Registration is serialised across replicas with a database advisory lock, so two replicas importing the same device cannot hand out the same _2. Each reconcile that changes something writes one line to the system log, not one per entity: an import of five thousand points is one event.

GET /api/states/{entity_id}:

{
"entity_id": "sensor.press_01_zone1_temp",
"state": "212.5",
"attributes": {
"friendly_name": "Zone1_Temp",
"unit_of_measurement": "F",
"device_class": "temperature",
"source": ["press-01/Zone1_Temp"]
},
"quality": "good",
"last_changed": "2026-09-26T14:02:11.482Z",
"last_updated": "2026-09-26T14:02:11.482Z",
"last_reported": "2026-09-26T14:07:40.100Z"
}

state is the value rendered without its unit: a number the way /api/values displays it, on or off for binary_sensor and switch, the text itself for a string. The unit is an attribute. A state that is sometimes 212.5 and sometimes 212.5 F is a state nothing can compare.

The rules, in the order they are applied. They are the same rules the OPC UA server uses for its status codes, for the same reason: a state is never better than the evidence for it.

Condition state quality
Entity orphaned, or its tag is gone unavailable bad
Device disabled, deleted, or its link is not up unavailable bad
Link up, nothing read yet in this session unknown bad
Bad reading the last good value, or unknown if there never was one bad
Good reading, but other points on the device are failing (degraded) the value uncertain
Good reading the value good

The bad-reading rule matters more than it looks. A driver reports a point it could not read as a bad reading of zero, and showing that zero as the state turns “the level transmitter stopped answering” into “the tank is empty”. The entity keeps showing the last value it could trust and says, in quality, that it can no longer vouch for it.

Note the difference between a point failing and a device failing. A Modbus device whose every block fails drops its session and reconnects, so its entities go unavailable. A device that answers for some points and not others stays connected (degraded), and only the failing points’ entities show bad.

How fast a dead Modbus device shows. One that refuses or closes the connection is unavailable by its next poll. One that simply stops answering (a pulled cable, a hung PLC, a switch that rebooted) keeps its socket open and is judged by its poll timeout instead: its entities are unavailable within one poll interval plus twice the device’s timeout, plus at most a second for the engine’s backstop recompute. Usually it is one timeout; twice is when the device dies while a poll is part way through, because the request already on the wire gets its full timeout. Until then nothing new has been read, so the entity still shows its last reading as it was. A hung device that still accepts connections is reconnected straight away, so after that its entities alternate between unavailable and unknown (connected, nothing read) until it answers again; neither is ever mistaken for a value. The timeout is the knob: a device on a 20 second timeout can look fine for 20 to 40 seconds after it dies, so do not set it longer than the device needs to answer.

That table is for entities of one tag. A composite is stricter (a bad reading never keeps its last value, see How a composite’s state is decided), and an alarm entity’s state is the alarm engine’s (see Alarm entities).

  • last_changed moves only when state changes.
  • last_updated moves when state, quality or any attribute changes.
  • last_reported is the timestamp of the reading the state came from. It is absent until there is one.

State is computed, not stored, but the logbook keeps every entity’s newest state and when it last changed (entity_last_state, see LOGBOOK.md). After a restart, an entity whose state comes back the same keeps its last_changed: a pump running since Tuesday still says Tuesday. The unavailable and unknown a tag entity passes through while its device reconnects do not count as a change. A state that did change while the Gateway was down gets a fresh last_changed, and a line in the logbook. Numeric entities (sensors, numbers, loops, counters) and buttons are not tracked, so theirs, like every entity’s last_updated, starts again from the restart.

Every entity carries friendly_name and source (the tag keys it is bound to). Then, when they apply:

Attribute When
unit_of_measurement sensor and number: the entity’s own unit, or its tag’s
device_class, state_class, icon when set on the entity
armed control entities: whether the tag is writable right now
min, max number: the entity’s options, else the tag’s range if it has one
step number: the entity’s option, else 1 for an integer tag
max_length, pattern text: the entity’s options, max_length defaulting to 255
quality_reason tag entities, while the reading isn’t good and the driver knows why: no field at ENERGY.Power, payload is not JSON. Gone again with the next good reading

A disabled entity has no state at all: it is absent from /api/states, and asking for it by id says it is disabled rather than missing. A hidden entity has its state as usual; the flag is for screens to leave it off default views.

The engine subscribes to the tag bus as entities with a queue of 256 batches, the same lossy contract as the alarm and automation engines: a slow consumer loses batches, it never stalls a poll. A dropped batch here costs at most a second, because the engine also recomputes every entity from the cache once a second. That tick is also what catches changes nothing announces, such as a device’s first poll landing.

Subscribers inside the process register with Engine.OnStateChanged. They are called on the engine’s own goroutine, in the order the changes happened, which is what a rule that cares about “from X to Y” needs. They must be quick. The engine never waits for them; if they fall more than 10,000 changes behind, the oldest are dropped and counted.

GET /api/diagnostics reports it under entities:

Field Meaning
entities how many entities have a state
droppedBatches bus batches the engine was too slow to take (also under bus.droppedPerSubscriber.entities)
pendingChanges, droppedChanges the state-change hook queue. A growing backlog, or any drop, means a hook is too slow

A device class says what a value is (temperature, pressure, volume_flow_rate, running, problem) so a screen can pick an icon and a statistics consumer knows what to do with it. Each class has the units it usually comes in. The full table is model.DeviceClasses.

  • An unknown device class for the domain is refused.
  • A unit the class does not usually have is saved, with a warning in the response. Plants label things the way plants label things, and degF on a temperature is not worth blocking a save over. Plain F and C are accepted for temperature without a warning, because that is what most PLC tag databases actually carry.
  • state_class (measurement, total, total_increasing) is for sensors only.
  • There is no unit conversion. The unit is a label.

Entity endpoints use snake_case field names (entity_id, last_changed) rather than this API’s usual camelCase. That is on purpose: the entity API exists so that anything which already understands that shape understands this one.

Endpoint Permission What
GET /api/entities entities.read Every entity, orphans included. Filters: ?domain=, ?device=, ?orphaned=true|false
GET /api/entities/{entity_id} entities.read {"entity": ..., "state": ...}; state is null for a disabled entity
GET /api/entities/{entity_id}/image entities.read a camera’s or an image’s latest picture
PUT /api/entities/{entity_id} entities.write Change registry fields, including a rename. Answers {"entity": ..., "warnings": [...]}
POST /api/entities entities.write Create a composite. Answers 201 {"entity": ..., "warnings": [...]}. See Composite equipment
DELETE /api/entities/{entity_id} entities.write Delete a composite or an orphan. Anything else is 409
GET /api/states entities.read Every enabled entity’s state, ordered by entity_id. Filter: ?domain=
GET /api/states/{entity_id} entities.read One state
POST /api/services/{domain}/{service} values.write (alarms.ack for alarm) Call a service on one entity. See Services. ?dry_run=true asks what it would do instead; see Asking first
GET /api/writes?entity={entity_id} values.read Every write service calls made or had refused on one entity, newest first

Every role holds entities.read: an entity’s state is a tag’s value under another name, which a viewer can already read. Only admin holds entities.write: the entity_id is the name every automation, dashboard and agent refers to the entity by, and changing it is configuration, not operation.

A PUT body changes only the fields it names. Unknown fields are refused, and so are the identity fields (unique_id, domain, platform, the device and the bindings):

{
"entity_id": "sensor.zone_1_temperature",
"name": "Zone 1 temperature",
"device_class": "temperature",
"state_class": "measurement",
"unit_of_measurement": "°F",
"icon": "mdi:thermometer",
"entity_category": "",
"options": {},
"disabled": false,
"hidden": false
}

options takes min, max and step on a number (they narrow what a screen offers; the write gate still checks the tag’s own range regardless) and max_length and pattern on a text. A composite’s options are its own (see each domain below), and a PUT that changes them is checked against its tags exactly as the create was. sensor, binary_sensor, switch and alarm take none.

An entity also has a place on the equipment tree and a set of labels: equipment_id (null to follow its device), the read-only area_id clients group by, and labels. PUT takes equipment_id and labels like any other field. See EQUIPMENT.md.

A service is what you can do to an entity: switch.turn_on, number.set_value. It names the thing you want done to the entity, and underneath it is nothing but tag writes.

POST /api/services/switch/turn_on
{"entity_id": "switch.press_01_pump", "reason": "start of shift"}
POST /api/services/number/set_value
{"entity_id": "number.press_01_zone1_sp", "value": 212.5}

The body is entity_id, an optional reason, and the service data. One entity per call, never a list, because the answer to “what went out” should be about one entity.

Service Data Writes
switch.turn_on, switch.turn_off none true or false to the command tag
switch.toggle none the opposite of the current state, read from the live cache by the same rules as the state itself. If the state is unknown, unavailable or bad quality, the call is refused: toggling something whose state you do not know is guessing which way to move it. Say turn_on or turn_off
number.set_value value, a number the value, to the command tag
text.set_value value, a string the string, to the command tag
select.select_option option the option’s raw value, to the command tag
button.press none press_value, then pulse_ms later release_value, to the command tag; a single_shot button writes press_value once and nothing after
motor.start, motor.stop none true or false to run_command
motor.reset none a pulse on reset_command: true, then pulse_ms later false
motor.set_speed value, a number the value, to speed_command
valve.open, valve.close none true or false to command; on a modulating valve, 100 or 0 to position_command
valve.stop none a pulse on stop_command; a single_shot valve writes true once
valve.set_position position, 0 to 100 the position, to position_command
loop.set_setpoint value, a number the value, to setpoint
loop.set_mode mode: auto, manual or cascade the mode’s raw value, to mode
loop.set_output value, a number the value, to output, only while the loop reads manual
counter.reset none 0 to a numeric reset_command, or a pulse on a bool one
alarm.acknowledge note, optional no tag: the alarm engine’s acknowledge
alarm.shelve minutes, 1 to 1440 no tag: the alarm engine’s shelve
alarm.unshelve none no tag: the alarm engine’s unshelve, only on a shelved alarm
script.turn_on the script’s fields no tag itself: runs the script, whose steps write through the gate as script:<id>
script.turn_off none no tag: stops every run of the script
scene.turn_on params, the recipe’s parameters, and version, both optional no tag itself: applies the recipe, whose targets write through the gate under one call id
light.turn_on brightness, 0 to 100, optional true to command, then the brightness to brightness_command
light.turn_off none false to command
light.toggle none the opposite of what the light’s state feedback says; refused when that can’t be read
cover.open, cover.close, cover.stop none open, close or stop to command
cover.set_position position, 0 to 100 the position, to position_command
lock.lock, lock.unlock, lock.open none lock, unlock or open to state
fan.turn_on percentage, preset_mode, both optional true to state, then each one given
fan.turn_off, fan.toggle none false, or the opposite of what its feedback says, to state
fan.set_percentage percentage, 0 to 100 to percentage
fan.set_preset_mode, fan.set_direction preset_mode, direction to preset_mode, direction
fan.oscillate oscillating, true or false to oscillating
siren.turn_on, siren.turn_off, siren.toggle none like a switch, to state
notify.send_message message the message, to message

A composite service whose role is not bound (set_speed on a motor with no speed_command) is refused before anything is planned, as an unknown service.

sensor and binary_sensor are read-only and take no services. Service data with a field the service does not take is refused, the same way a misspelled field in any other body is: a call that quietly goes ahead without the field you meant is worse than one that says no.

A number that is a whole number reaches an integer tag as an integer: 42.0 is 42.

A service call becomes an ordered list of writes, and every one goes through runtime.Manager.Write. So everything the gate enforces on a write to a tag, it enforces on a service call, in the same order:

  1. writes enabled (safety.allowWrites), and for an agent, agent writes enabled;
  2. the tag armed (writable);
  3. the value inside the tag’s range, rejected, never clamped;
  4. then what only the entity knows, checked inside the gate against the entity and the tag as they are at that moment:
    • the entity is not disabled (commanding something that has no state is not allowed);
    • a number’s own min and max, which can only narrow the tag’s range;
    • a number’s step, counted from its minimum (its own, else the tag’s range minimum, else zero). An integer tag has a step of 1 unless the entity says otherwise, so 1.5 to a holding register is refused instead of a driver quietly truncating it to 1;
    • a text’s max_length (characters, 255 when unset) and its pattern, which the whole value must match;
    • a select option or a loop mode that is not one of the entity’s. It is sent to the gate as the option or mode asked for, and refused there, so somebody trying options that do not exist is on the record;
    • a valve position outside 0 to 100;
    • a loop’s set_output while its mode tag, read from the live cache at that moment, is anything but manual (auto, cascade, a value that is none of the modes, or unreadable).

Every refusal, at any of those steps, is audited: a write_audit row with the entity, the service and the call id on it, succeeded false and the reason starting refused:. That includes a user without values.write. The API does not just answer 403 and forget it: it sends the call to the gate marked denied, the gate refuses it before anything is written, and the refusal is on the entity’s record with the value that was asked for. A write straight to a tag through POST /api/write behaves exactly as it always did, byte for byte; TestPostWriteIsByteIdenticalToBeforeEntityServices holds it there.

Nothing in internal/entity can reach a device any other way. It does not import a driver, and TestOnlyTheGateCallsWorkerWrite type-checks the runtime and fails the build if anything but Manager.Write calls the worker’s write, or anything but that calls a driver’s. It matches the shape of a device write, not a name, so a bypass through a new wrapper or an interface declared on the spot is caught too. TestGateGuardCatchesABypass plants each of those in a copy of the package to prove the guard can fail.

{
"call_id": "9f2c4e1a7b3d5c60",
"entity_id": "switch.press_01_pump",
"service": "switch.turn_on",
"writes": [
{"tag": "press-01/pump", "value": "true", "status": "written"}
]
}

Each write has a status:

Status Meaning
written the device accepted it
not_sent the gate turned it away before it reached the device: a refusal, no session, an audit row that could not be opened
failed it went out and the device or the connection reported an error. Whether it took is not known
skipped never attempted, because an earlier write in the same call did not succeed

A failed call answers with the error’s status code (403 refused by policy or permission, 422 a value refused, 409 a toggle with no known state or equipment in the wrong state for the service, 503 the device not connected, 502 the device took the write and reported an error, 404 no such entity, 400 a malformed call, 500 a press whose release did not go through, or a fault inside the Gateway, whose error is only a reference to the server log) and the same body with error added.

The writes of one call go out in order, the first that does not succeed ends the call, and nothing already written is rolled back. Undoing is itself a write, made without anybody asking for it, to equipment that has already moved. Deciding that is the operator’s job, and the answer tells them exactly where the call stopped. Every write of one call shares its call_id, in the audit trail and in the answer.

There is one exception, and it is the momentary command: a button’s press, a motor’s reset, a valve’s stop, a counter’s reset on a bit. Those are two writes, the press and pulse_ms later the release, and the release is not something to skip lightly, because skipping it leaves a pushbutton held down. So:

  • The release is still sent when the press went out and reported an error (failed): it may have taken. It is skipped only when the press was not_sent, because then nothing is held.
  • The release is sent even if the caller hangs up during the pulse. The call carries on without it.
  • If the press was written and the release was refused or failed, the call fails with a 500 and says so: the tag may still hold the press value, check it and release it by hand. The release’s refusal or failure is in the audit trail under the same call id, and the call is logged under the entity at severity error, not warning.

A pulse is pulse_ms long, 500 by default, at most 10,000. The call answers when the release is done, so a caller waits for the pulse.

Each call is also one line in the system log, under the entity_id, category write, with the reason and, in the detail, the call id, the source and every write with its status. The individual tag writes keep their own lines under their tags, as they always have.

GET /api/writes?entity=switch.press_01_pump is that entity’s audit trail, refusals included, newest first. The rows have entityId, service and callId; a write straight to a tag leaves them out, so every existing reader of /api/writes sees exactly what it saw before. Migration 0007_service_audit.sql adds the three columns, empty for every existing row.

POST /api/services/{domain}/{service}?dry_run=true asks the gate what it would do with a call, and does none of it. Same body, same permission.

{"dry_run":true,"entity_id":"number.press_01_zone1_sp","service":"number.set_value","writes":[{"tag":"press-01/zone1_sp","value":"212.5","status":"would_write"}]}
{"dry_run":true,"error":"press-01/zone1_sp: this tag is not marked writable","entity_id":"number.press_01_zone1_sp","service":"number.set_value","writes":[{"tag":"press-01/zone1_sp","value":"212.5","status":"not_sent","error":"press-01/zone1_sp: this tag is not marked writable"}]}

The call is planned exactly as the real one is, and every write it would make is put to the gate’s own checks, in the same order, against the tags, the entity and the device as they are right now: writes on, the agent switch, the tag armed, its range, everything only the entity knows (step, pattern, a loop in manual, a disabled entity), and a session to the device. The first write that would be refused is not_sent with the reason, the rest skipped, and error is the error the real call would fail with, word for word. A write that would go out is would_write. It is one function in the gate (precheck) answering both, so a dry run cannot pass on checks the real write does not make. An alarm entity writes no tag, so its dry run makes the alarm checks instead (the engine running, the definition still there, the entity enabled, only a shelved alarm unshelved) and answers with no writes; the alarm is not touched and the alarm trail gets nothing.

It is a question, so a verdict, refused or not, answers 200. A request the real call would turn away before any check (no such entity, a service the domain does not take, data it does not accept, a toggle with no known state, the wrong domain in the path) answers with the same status and error the real call would. A user without the permission gets the same 403.

A dry run writes nothing, opens no audit row, puts nothing in the system log and spends no call id: nothing was attempted, so there is nothing to account for. TestCheckWriteCannotReachTheDeviceOrTheAudit walks every function the gate’s dry run can reach and fails if the device write, the audit or the log is among them; TestCheckAnswersExactlyWhatTheCallWould runs each refusal a plant relies on as a dry run and then for real against a Modbus device and holds the two to the same words.

It says what would happen right now. Between the dry run and the real call the device can drop and somebody can disarm the tag; the real call is checked again, in full, at the moment of the write. One case is answered more precisely than the real call’s own checks: a device whose session has dropped while its worker reconnects. The real write gets as far as the driver and fails there (failed, 503, and an audit row for the attempt); the dry run says not_sent up front, with the same error.

The command prompt on the Entities screen and in the entity drawer uses it: as the prompt opens and as a value is typed, it asks, and a refusal shows in the prompt, in the gate’s words, before anybody presses confirm. It is advice, not a lock: confirm still goes to the gate, which decides again.

A tag maps onto one entity. A pump does not map onto one tag: it has a run command, a running contact, a fault, a reset and maybe a speed, and the operator thinks of it as one thing. A composite is that one thing, bound to each of its tags in a named role.

An admin builds one with POST /api/entities:

{
"domain": "motor",
"name": "Infeed pump",
"device_class": "pump",
"options": {"pulse_ms": 500},
"bindings": [
{"role": "running", "device_id": "press-01", "tag_name": "P101_Running"},
{"role": "run_command", "device_id": "press-01", "tag_name": "P101_RunCmd"},
{"role": "fault", "device_id": "press-01", "tag_name": "P101_Fault"},
{"role": "reset_command", "device_id": "press-01", "tag_name": "P101_Reset"}
]
}

entity_id is optional. Leave it out and one is made from the name (motor.infeed_pump, then _2 and so on). Give it and it is used exactly, and a taken one is a 409, not a quiet _2: you asked for that name. name, device_class, unit_of_measurement, icon, entity_category and options are the same registry fields every entity has. Place it on the equipment tree and label it with PUT afterwards.

Creation checks everything it can against the tags as they are now, and refuses (400, saying what to fix) anything that is not right:

  • the domain is one of select, button, motor, valve, loop, counter. The tag domains are made for you from tags, and alarm from alarm definitions;
  • every required role is bound, and no role the domain does not have;
  • every bound tag exists;
  • every tag’s type fits its role: a motor’s running is a bool, a valve’s position is a number, a counter’s count is an integer;
  • every tag a service writes is already armed (writable). A composite never arms anything. Arming a point stays a decision somebody makes about the tag, one at a time, the same as it always was;
  • every raw value in the options (a select option’s value, a button’s press and release values, a loop’s modes) is one its tag can take. 1.5 is refused for an integer register now, not when somebody presses the button.

A composite goes with its device when all its tags are on one device, and belongs to none when they span several. Its bindings are fixed: a different set of tags is a different entity, so delete it and build another.

Every creation and deletion is one line in the system log under the entity_id, category config, with the bindings.

Domain Role Tag type Details
select state any required. Feedback: read back to find the option
command any required, armed. Written with the option’s raw value. May be the same tag as state
button command any required, armed. Pulsed
motor running bool required. Feedback: the running contact
run_command bool required, armed. start writes true, stop false
fault bool feedback
reset_command bool armed. Pulsed by reset
speed number feedback, the speed attribute
speed_command number armed. Written by set_speed
valve state any feedback: where a valve that announced itself says it is, in words
opened, closed bool feedback: the limit switches
position number feedback, 0 to 100 percent
fault bool feedback
command bool armed. An on/off valve: open writes true, close false
position_command number armed. A modulating valve: set_position, and open/close as 100/0
stop_command bool armed. Pulsed by stop
loop pv number required. The state
setpoint number required, armed. Read for the attribute, written by set_setpoint
output number required, armed. Read for the attribute, written by set_output
mode any required, armed. Read to know the mode, written by set_mode
counter state integer required. The count
reset_command bool or number armed. Pulsed if a bool, written with 0 if a number (it may be the count register itself)

A valve needs at least one of state, opened, closed and position (something has to say where it is) and exactly one of command and position_command. Both at once would be two answers to “where has it been told to go”, and the state between the limits depends on that answer. If the PLC really has both, make two entities.

Option Domain Details
select_options select required: [{"option": "low", "value": 1}, ...], in the order a screen offers them. Names unique, values unique (two options with one value would make the state a guess), each value a number, a bool or a string
press_value, release_value button together or not at all. Left out on a bool tag, they are true and false, written into the options so what the button does is on the record. Any other tag type needs both
pulse_ms button, motor, valve, counter how long a momentary command is held. 1 to 10,000, 500 when unset
single_shot button, valve true writes press_value once and never releases: the press is the whole command. A discovered MQTT button is one, because its “release” would be a second command the device never asked for. Takes press_value and no release_value or pulse_ms. On a valve, its stop writes true once instead of a pulse: a discovered valve’s stop is a command of its own
event_types event the event types it can fire, when it says
role_choices lock, fan, siren, notify, update, device_tracker, climate, humidifier, water_heater the words each commanded role offers, by role, 1 to 64 distinct each: a thermostat’s modes. Discovery sets it from the config
modes loop required: the raw value of the mode tag for each mode, {"auto": 1, "manual": 0} at least, cascade if the controller has it

A composite’s state comes from its feedback. It is never computed from the fact that HotLoop sent a command. A motor told to start whose running contact has not made is not running, it is starting, and if the contact never makes it stays starting until somebody looks. The read-back of a command tag does count as feedback: that is the PLC saying what it holds, not HotLoop remembering what it wrote.

A composite is never optimistic. A single sensor keeps showing its last good value through a bad reading; a composite does not. Every feedback role that decides the state must have a good reading, or:

Condition state quality
a deciding tag is gone, or its device is down unavailable bad
a deciding tag has no reading yet, or a bad one unknown bad
every deciding tag good, one of them uncertain or on a degraded device the state uncertain
every deciding tag good the state good

A motor whose fault input stopped answering is unknown, not the running it was a second ago. Which roles decide is below; the others only fill attributes, and an unreadable one is simply left out.

A composite’s armed attribute is true only when every tag any of its services writes is armed right now. A motor that can be started but not stopped from here is not one a screen should offer.

Deciding roles: running, and fault if bound. State, first rule that matches:

  1. fault on: fault, whatever else is true. Running and fault together is contradictory, and the one that needs a person is the fault; the running attribute still says it is turning.
  2. running, and the run command reads back off: stopping.
  3. running: running.
  4. not running, and the run command reads back on: starting.
  5. otherwise: stopped.

If the run command’s read-back is unreadable it decides nothing: the motor is then running or stopped by its contact alone. HotLoop does not time out a start that never makes: that is a fail-to-start, and it belongs in the PLC or in an alarm definition on the running contact.

Attributes: running, fault (if bound), command (the read-back), speed (if bound and readable), device_class (pump, fan, conveyor, compressor, agitator).

A valve that announced itself (MQTT discovery) says where it is in words on its state role: open, closed, opening, closing or stopped, and that is its state, with current_position from position when it reports one. A word that is none of those is unknown, with what it said in raw_value. The rest of this section is a valve built from PLC tags.

Deciding roles: every bound one of opened, closed, position, fault. State, first rule that matches:

  1. fault on: fault.
  2. both limit switches made: fault, with limit_conflict: true. A valve cannot be open and closed, so one switch is lying, and which one is not something to guess.
  3. open limit made: open. Closed limit made: closed.
  4. no limit switches, only a position: at or below 1% closed, at or above 99% open.
  5. between the ends, travelling or stopped by what it is commanded to: a position command read back more than 1% above the position is opening, more than 1% below closing, within 1% stopped; an on/off command read back on is opening, off is closing; with nothing to go by, stopped.

An on/off valve stopped between its limits while still commanded open reads opening. If you need to see it stopped there, bind position.

current_position is the position feedback when bound, else 100 on the open limit and 0 on the closed one, and absent in between. Other attributes: open_limit, closed_limit, fault, device_class (water, gas, steam, air).

The state is the process variable, rendered like a sensor’s, from pv alone. The attributes are setpoint and output (present when readable), mode (the name of the mode the mode tag reads, or unknown when it cannot be read or holds a value that is none of the modes), modes (the configured names), and unit_of_measurement (the entity’s, else the PV tag’s). A loop takes the sensor device classes: a temperature loop, a pressure loop.

set_output is refused inside the gate unless the loop reads manual at the moment of the write. An output written to a loop in auto fights the controller, and in cascade it fights two. A mode that cannot be read is not manual. The refusal is a write_audit row like any other, with the entity, the service, the call id and the value asked for, and the call answers 409. Put the loop in manual with set_mode first.

The state is the count, an integer tag. reset writes 0 to a numeric reset_command (bind the count register itself if that is how the PLC resets it) or pulses a bool one. A counter with no reset_command is read-only. unit_of_measurement is the entity’s, else the tag’s.

The state is the option whose raw value the state tag reads. A value that is none of the options is unknown, with the raw value in raw_value, rather than the nearest option or the last one. The options attribute lists the option names in order. An option that is not one of the select’s is refused in the gate and audited with the option that was asked for.

A button has no feedback. Its state is the time of its last press through this Gateway, or unknown if it has not been pressed since the Gateway started. It is unavailable when its command tag is gone or its device is down, because pressing it then cannot work. The last press is held in memory, so it resets on a restart; the audit trail has every press regardless, which is also why the logbook does not log a button’s state.

Made by MQTT discovery for a device that announced one, bound to the tags discovery made for it. Nobody builds these out of PLC tags, so POST /api/entities doesn’t take them. Same rules as every composite: the state is what the feedback says, never what HotLoop last sent, and every service is writes through the gate, each tag armed by a person first.

Domain Role Tag type Details
light state bool feedback: on or off
command bool armed. turn_on writes true, turn_off false. The same tag as state
brightness number feedback, 0 to 100 percent, the brightness attribute
brightness_command number armed. turn_on with a brightness. The same tag as brightness
cover state any feedback: open, closed, opening, closing or stopped
command any armed. open, close or stop. The same tag as state
position number feedback, 0 (closed) to 100 (open), current_position
position_command number armed. set_position. The same tag as position
event state any the event type it last fired
lock state any read and written: locked, unlocked, locking, unlocking, jammed, open, opening; takes lock, unlock, open
fan state bool read and written: on or off
percentage number read and written, 0 to 100
preset_mode, direction any read and written
oscillating bool read and written
siren state bool read and written: on or off
notify message any written: the message. Nothing to read
update installed_version, latest_version any read
device_tracker state any read: home, not_home, or the zone it names
climate mode any read and written: the state. set_hvac_mode with hvac_mode
temperature, target_temp_low, target_temp_high number read and written. set_temperature with any of them
target_humidity number read and written. set_humidity with humidity
fan_mode, swing_mode, swing_horizontal_mode, preset_mode any read and written, each by its own set_ service
current_temperature, current_humidity, action number, number, any read
power bool written: turn_on true, turn_off false. Nothing to read
humidifier state bool read and written: on or off
target_humidity number read and written. set_humidity with humidity
mode any read and written. set_mode with mode
current_humidity, action number, any read
water_heater mode any read and written: the state. set_operation_mode with operation_mode
temperature number read and written. set_temperature
current_temperature number read
power bool written: turn_on, turn_off
alarm_control_panel state any read: disarmed, armed_home, armed_away, armed_night, armed_vacation, armed_custom_bypass, pending, triggered, arming, disarming; written by alarm_arm_home, alarm_arm_away, alarm_arm_night, alarm_arm_vacation, alarm_arm_custom_bypass, alarm_disarm, alarm_trigger
vacuum state any read: cleaning, docked, paused, idle, returning, error; written by start, pause, stop, return_to_base, clean_spot, locate
battery_level number read
fan_speed any read and written. set_fan_speed with fan_speed
lawn_mower activity any read: mowing, paused, docked, error, returning
camera image any read: a hash of the latest picture. The state is when it changed; entity_picture is where to get it
image image or url any read: a held picture as for a camera, or a link (url, http or https only, never fetched)
start_mowing, pause, dock bool written: a press each, by the service of the same name

A lock, a fan, a siren, a notifier, an update, a tracker, a thermostat, a humidifier, a water heater, an alarm panel, a vacuum and a mower are one table in the code (model/things_table.go): which role is the state and what each service writes. The state is the state role’s reading (on or off for a bool), unknown when it can’t be read, and every other role that can be read is an attribute under its own name: a fan’s percentage, preset_mode, oscillating and direction. toggle goes by the feedback and is refused when there is none. A notifier has nothing to read: its state is unknown with feedback: none. An update is on when what is installed isn’t the latest, and takes no services: HotLoop never installs firmware, because a failed flash is an outage. A discovered scene is a single-shot button, its press the scene’s payload.

What a thing can be sent is in its attributes, by its config: a thermostat’s hvac_modes, fan_modes, preset_modes and swing_modes, a humidifier’s available_modes, a water heater’s operation_list, a fan’s preset_modes, a vacuum’s fan_speed_list (from the entity’s role_choices option, which discovery keeps in step with the config), and the setpoint’s min_temp/max_temp and min_humidity/max_humidity (from the tag’s range). They’re what the screens offer. They aren’t what makes the rule: the gate holds the setpoint to the tag’s range and the driver refuses a word the config doesn’t list, whatever an attribute says. set_temperature with none of its three keys is refused, not a call that does nothing. turn_on on a thermostat with no power topic is refused too, rather than guessing which mode “on” means.

On or off by its state feedback; unknown when that can’t be read, and with no state topic at all, unknown for good with feedback: none. Brightness is an attribute, and a JSON-schema light’s color_mode, color, color_temp and effect are attributes too: shown, not commanded. toggle goes by the feedback and is refused when there is none to go by.

Its state feedback when it has one. With only a position: at or below 1% closed, anything above it open. A word that is none of the five is unknown, with raw_value. current_position is the position when there is one.

An event’s state is when it last fired (RFC 3339), and event_type what it was; event_types lists what it can fire. unknown until it has fired since HotLoop started reading it. It takes no services: nothing commands an event. Two events in one poll show as the later one; the logbook and the history have each reading as it came.

Every alarm definition is an entity: alarm.<definition id> (slugged the same way a tag’s is, so oven-temp-hi is alarm.oven_temp_hi), platform alarm, unique_id alarm:<definition id>, named after the definition. It has no bindings and no device: its state is the alarm engine’s, not a tag’s, and a definition does not belong to its device the way a tag does (deleting a device leaves its alarm definitions). The tag it watches is its source attribute.

The state is the ISA-18.2 state the alarm engine holds, spelled the way an entity state is spelled:

Alarm engine Entity state Details
normal normal condition clear, nothing outstanding
unack-active active_unack condition true, nobody has acknowledged it
ack-active active_ack condition true, acknowledged
rtn-unack rtn_unack cleared before anybody acknowledged it
shelved shelved silenced by an operator, for a bounded time
suppressed suppressed silenced by design

Quality is good: the engine’s state is not a reading. The entity is unavailable when it is orphaned or the Gateway has no alarm engine. It follows every transition as it happens, acknowledgements included.

Attributes: definition_id, source, condition, limit, priority (1 to 4), priority_label, enabled, message, count, active, needs_ack, trip_value, raised_at, cleared_at, acked_at, acked_by, shelved_until, shelved_by, the last ones present when they apply.

Its services, acknowledge, shelve and unshelve, write no tag, so they do not go through the write gate. They call the alarm engine’s own Ack, Shelve and Unshelve, the same ones the alarm screen and POST /api/alarms/{id}/ack call, so they land in the alarm trail (alarm_events) and the system log exactly as those do. They need alarms.ack, not values.write: an operator acknowledges alarms, a viewer does not. A refused call answers 403 and is logged under the entity with who tried. The call is also one line in the system log under the entity, with the reason.

unshelve is refused (409) on an alarm that is not shelved. The alarm engine refuses it too, and brings a shelved alarm back in the state its condition is actually in, not blindly to normal. A service alarms do not take (turn_on) is refused as an unknown service (400) before it goes anywhere near the engine.

Alarm entities are the first provider platform (internal/entity/provider.go): entities whose source of truth is somewhere other than a tag. A provider says which entities its platform has right now, renders their state, runs their services and answers their dry runs; the registry derives, orphans and re-adopts its entities by unique_id exactly as it does a tag’s, the state engine recomputes them on the provider’s own events and on its once-a-second tick, and a service call on one goes to the provider instead of being planned into tag writes. A provider that writes a tag still writes it through the gate. The domains and the services each takes are one table (model.DomainSpec), so a platform’s new domains are registered in a file of their own without touching the ones here, which TestBuiltInDomainsAreUnchanged holds byte for byte.

Helpers are the second (internal/helper): input_boolean, input_number, input_select, input_text, input_counter, timer and schedule, values the plant’s people own rather than a PLC, kept by HotLoop and set with helpers.write. They write no tag. See HELPERS.md.

Scripts are the third (internal/automation): every script is a script entity, script.<id>, on while a run of it is going in any process and off when none is. turn_on runs it with the service data as its fields, turn_off stops it, both with scripts.run. Deleting a script orphans its entity, like deleting an alarm definition. See SCRIPTS.md.

Recipes are the fourth (internal/recipe): every recipe is a scene entity, scene.<id>, whose state is when it last wrote to the plant and whose attributes say how its newest application went. turn_on applies it, with recipes.apply and the recipe’s own permission when it names one. Deleting a recipe orphans its entity. See RECIPES.md.

The sun is the fifth (internal/sun): binary_sensor.sun_up, sensor.sun_elevation and sensor.sun_azimuth, worked out every second from the site’s latitude and longitude, on the box. They read and take no services. A site with no location has none, and ones left over from when it had one read unavailable and say which variables to set. See SUN.md.

GET /api/stream carries an entities event next to values. It is coalesced the same way, four times a second at most, the newest state per entity:

event: entities
data: {"changed":[{"entity_id":"sensor.press_01_zone1_temp","state":"212.5",...}],"removed":[]}

changed is the full state of every entity that changed since the last event, in the same shape as GET /api/states/{entity_id}. removed is the entity_ids that no longer have a state at all: disabled, deleted with their device, or renamed away from (the new name arrives in changed). The first entities event on a new connection carries every state, the way snapshot carries every reading, so a screen is populated before anything changes. The values, snapshot, alarm and device events are exactly what they were.

Agents get list_entities, get_states and call_service. call_service runs through the same service runner as POST /api/services/..., so an agent’s call is gated by the MCP switch like write_tag and audited against the entity with source mcp. See MCP.md.

A state trigger fires a rule when an entity’s state changes, conditions can read states, attrs and entity qualities, and a call_service action commands an entity through the gate, audited as automation:<rule-id>. See AUTOMATIONS.md.

Both follow the engine’s OnStateChanged hook, which is why the rules in How the engine runs matter: a state trigger sees every change the engine computes, once, in order, and nothing the engine never saw.

Entities lists every entity under its place on the equipment tree (its own place, else its device’s), with the tree down the side, and filters by place, domain, label, text and quality. Inside a place the equipment comes first (alarms, motors, valves, loops, selects, buttons, counters), then what can be commanded, then the raw readings.

By default the screen shows each signal once. The entity model gives one point several entities on purpose, and every one of them keeps its id, but drawn as tiles that is the same signal two or three times: an armed tag is a sensor and a switch, and a tag bound into a motor is still its own sensor. On a real line that roughly doubled the tiles and buried the equipment. So the screen folds:

  • A tag’s read entity sits behind its control entity. The switch or number is the tile, since it already shows the live state, and its drawer says “also available as” with the sensor’s entity_id, for whoever is wiring an automation or an agent to it.
  • A tag bound into a composite sits inside the composite, both its entities. The composite’s drawer lists its signals: the roles, the tag, what it reads now, and its read and control entity_ids. A tag in two composites goes into the first by name.
  • Only a control or a composite that is neither hidden nor disabled takes anything in. Hide or disable the switch and its sensor gets its tile back.
  • An alarm on a folded signal flags the tile it is folded into, with the alarm’s name, and opens the alarm from there. A fold tidies the screen; it never hides an alarm.
  • The count next to the quality bar says “13 shown · 25 folded”, and a tile that holds others says so in its foot (“5 signals”, “+ sensor”). The domain chips, the tree counts and the quality bar count the tiles shown.
  • Search and the label filter look inside tiles. Search for an entity_id or a tag that is folded and you get the tile that holds it, outlined, with “Found in here:” and the entity_id. We picked that over flipping the whole screen to every entity: you land on the pump you were looking for, not on a wall of tiles.

“Show every entity” turns the fold off and gives every entity its own tile, for an engineer who wants the raw registry. It is off by default and this browser remembers it (local storage; a private window forgets it). Nobody else’s screen changes.

States arrive on the live feed’s entities event; the screen never polls for them. A tile repaints when its state does, and is repainted even while a button in it has focus, because a tile that kept an old state for that reason would be showing the past as the present. Quality is drawn before the value:

Quality On screen
good the value, no mark
uncertain the value, an amber edge and an “uncertain” badge
bad the last good value, greyed on a hatch, “bad”, and “last good value, reading bad”, with the reason after it when the driver gave one
no reading yet “Unknown”, in italics, and “reading bad” with the reason when there is one
unavailable “Unavailable”, the whole tile hatched

Controls follow the domain and the role. A viewer sees none. An operator sees the ones behind values.write (and an alarm’s behind alarms.ack), disabled with the reason on them when writes are off on this deployment or a tag the entity writes is not armed. A loop’s output is offered only while it reads manual, because that is the only time the gate allows it. Every command opens a prompt that says which service, which tags, and what the entity reads now, and takes a reason for the audit trail. A refusal keeps the prompt open with the gate’s words and every write that did or did not go out. The new state is never assumed from the command: it comes back on the feed when the device reads back, or it does not.

The drawer has the state and its timestamps, the controls, an hour, six hours or a day of the bound tag’s history (a step trace, the last value carried to now, bad readings marked), the other entities of the same signal, the bindings (for a composite, its signals), the attributes, and every command made or refused through the entity. An admin also gets the entity_id, the name, the place on the tree, the labels, hidden and disabled, and delete for a composite or an orphan.

New equipment (admin) builds a composite: pick the domain, name it, and choose a tag for each role. Each role is offered only tags of a type that fits it, and a command role shows the tags that are not armed as disabled instead of hiding them. What is required is marked but not enforced by the form: the registry decides, and its refusal is shown as it wrote it.

Plant is the equipment tree and the labels. See EQUIPMENT.md.

entities and entity_bindings are in the backup (internal/backup.Tables), entity_id, customisations and all. That includes composites, every role and every option, which matters more for them than for anything else: a tag’s entity could be rebuilt from its tag, but nothing can rebuild a composite except the backup. Alarm entities come back with their definitions, and a restored definition with no entity gets one on the first sync. A backup taken before this version has neither table; restore it, start the Gateway, and the registry’s first sync gives every restored tag its entity. entity_labels is in the backup too, and restoring a backup from before the equipment tree is covered in EQUIPMENT.md.

Migration 0007 adds three columns to write_audit. A backup taken before it still restores: restore loads a file with fewer columns than today’s table into the leading columns and lets the new ones take their defaults, because a migration that adds a column always appends it. A backup from a newer version is still refused. See BACKUP.md.

The core, the equipment tree, commanding entities through the write gate, composite equipment, alarm entities, entities in MCP, automations and the live feed, and the screens are all here. See ROADMAP.md. A state trigger’s forSec (“on for five minutes”) is in AUTOMATIONS.md.