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.
Why entities, when we already have tags
Section titled “Why entities, when we already have tags”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.
What you get without lifting a finger
Section titled “What you get without lifting a finger”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.
Entity IDs
Section titled “Entity IDs”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.
Deleted tags, orphans and re-adoption
Section titled “Deleted tags, orphans and re-adoption”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.
How the registry keeps up
Section titled “How the registry keeps up”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).
Timestamps
Section titled “Timestamps”last_changedmoves only whenstatechanges.last_updatedmoves whenstate,qualityor any attribute changes.last_reportedis 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.
Attributes
Section titled “Attributes”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 |
Disabled and hidden
Section titled “Disabled and hidden”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.
How the engine runs
Section titled “How the engine runs”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 |
Device classes and units
Section titled “Device classes and units”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
degFon a temperature is not worth blocking a save over. PlainFandCare 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.
Services
Section titled “Services”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.
What each domain takes
Section titled “What each domain takes”| 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.
Every call goes through the write gate
Section titled “Every call goes through the write gate”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:
- writes enabled (
safety.allowWrites), and for an agent, agent writes enabled; - the tag armed (
writable); - the value inside the tag’s range, rejected, never clamped;
- 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 ownminandmax, which can only narrow the tag’s range; - a
number’sstep, 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, so1.5to a holding register is refused instead of a driver quietly truncating it to1; - a
text’smax_length(characters, 255 when unset) and itspattern, which the whole value must match; - a
selectoption 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_outputwhile 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.
What a call answers
Section titled “What a call answers”{ "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 wasnot_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.
Asking first: the dry run
Section titled “Asking first: the dry run”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.
Composite equipment
Section titled “Composite equipment”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, andalarmfrom 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
runningis a bool, a valve’spositionis 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.5is 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.
Options
Section titled “Options”| 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 |
How a composite’s state is decided
Section titled “How a composite’s state is decided”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:
faulton:fault, whatever else is true. Running and fault together is contradictory, and the one that needs a person is the fault; therunningattribute still says it is turning.- running, and the run command reads back off:
stopping. - running:
running. - not running, and the run command reads back on:
starting. - 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:
faulton:fault.- both limit switches made:
fault, withlimit_conflict: true. A valve cannot be open and closed, so one switch is lying, and which one is not something to guess. - open limit made:
open. Closed limit made:closed. - no limit switches, only a position: at or below 1%
closed, at or above 99%open. - 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% belowclosing, within 1%stopped; an on/off command read back on isopening, off isclosing; 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.
counter
Section titled “counter”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.
select
Section titled “select”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.
button
Section titled “button”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.
Lights, covers and events
Section titled “Lights, covers and events”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.
Alarm entities
Section titled “Alarm entities”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.
Platforms with a provider
Section titled “Platforms with a provider”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.
Entities everywhere else
Section titled “Entities everywhere else”The live feed
Section titled “The live feed”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: entitiesdata: {"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.
Automations
Section titled “Automations”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.
Screens
Section titled “Screens”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.
Backup and restore
Section titled “Backup and restore”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.
What is not here yet
Section titled “What is not here yet”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.