Skip to content
GitHub

The equipment tree and labels

A plant already has a way of organizing itself, and every SCADA, MES and historian it owns uses it: ISA-95. Enterprise, site, area, line, cell, unit, equipment. So that is what HotLoop uses, one tree of it, and not a separate list for floors, another for areas and a third for devices. See ENTITIES.md for what hangs off it.

Level What it usually is
enterprise The company
site A plant
area A department or building: Packing, Utilities, Tank farm
line A production line
cell A work cell on a line, or a process cell in a batch plant
unit A production unit: a reactor, a chiller, a furnace
equipment A machine or a part of one: a pump, a skid, a motor

A child is always a lower level than its parent, so a site never goes under a line. It can skip levels: a unit straight under an area is fine, and so is a line straight under a site in a plant that has no areas. The one exception is equipment inside equipment, because a skid holds pumps and a pump holds a motor, and ISA-95 stops naming levels long before a plant stops nesting things. A node can be at the root at any level; most plants start at site.

Two siblings with the same level and name are the same place, so the tree refuses the second one.

A node can never become its own ancestor: moving a node under itself or anything below it is refused. The only way to get a loop is to edit the equipment table by hand. If somebody does, nothing that walks the tree hangs, and changing a node in the loop is refused with a message naming the nodes and telling you to set one of their parent_ids to NULL.

Every device has had free-text site, area and line fields since before the tree existed. They are not going anywhere. The 4.15 device editor writes them, list_devices and describe_device read them, and so does every agent and script somebody wrote against those. So the text and the tree are kept in step, in both directions, in the same transaction as the change:

Saving a device with text and no equipmentId finds the site, area and line the text names, or creates them, each under the one before it, and puts the device on the deepest one. Two devices with the same text land on the same node; nothing is ever duplicated. The text you sent is the text that is stored, exactly: a client that knows nothing about the tree gets back what it always got, plus an equipmentId it can ignore.

Saving a device with an equipmentId that moves it takes the text from the node’s path, whatever text came with it. Put a device on a cell under Plant A / Packing / Line 2 and its text becomes Plant A, Packing, Line 2.

Two details make that safe for clients that do not know about the tree:

  • Text that still says what the device’s current node says keeps the device where it is. The text only has three columns, so a device on a cell or a machine below its line can only say the line. Saving it with that same text does not knock it up to the line.
  • A client that reads a device and sends the whole thing back, equipmentId and all, with the text edited, gets the text edit. The equipmentId it echoed is the one the device already had, so it is not a move, and the text decides.

A site is found wherever it is, not just at the root, so putting Plant A under an enterprise does not make the next device that says Plant A create a second one.

Renaming or moving a node rewrites the text of every device at or below it, in the same transaction. If any of that fails, none of it happened: a tree that says one thing and device text that says another is exactly what keeping both was meant to prevent. The answer and the system log list every device whose text changed.

Migration 0006_hierarchy.sql builds the tree from the text every existing device already carries: one site per distinct site name, one area per distinct area name under its site, one line per distinct line under its area, and each device on the deepest node its text names. A device with an area and no site gets a root area; one with a line and no area gets a line straight under its site. Text that is only spaces names nothing. The text is used exactly as stored, spaces and case included, because the tree’s names are what the text gets rewritten from later, and trimming during the upgrade would be a silent rename of every device that had a stray space. No device’s text changes in the upgrade.

The Go side places a device by the same rules, and a test holds the two together: every device the upgrade placed, saved again untouched, stays where it is and grows the tree by nothing.

A node with anything under it is refused: child nodes, or devices placed on it. Move them first. The refusal names what is in the way.

The alternative was re-parenting the children onto the deleted node’s parent. That moves every device below it and rewrites their text, under a node nobody touched, and can put a line straight under a site with no area in between, which is a tree nobody drew. Unplacing the devices instead would leave their text naming a place that no longer exists, and the next save of any of them would quietly create the node again. Both are surprises. A refusal that says what to move is not.

Entities placed on a deleted node lose the placement and go back to following their device.

Every entity response now carries three more fields:

Field What
equipment_id Where the entity itself is placed, or null when it just goes where its device goes
area_id The nearest area at or above the entity’s own node, or its device’s node when it has none. Anything grouping by area reads this field. Null when there is no area above it
labels The entity’s label_ids

An entity’s own place wins over its device’s. That matters for a device that is a whole PLC: its temperature tag belongs in the furnace, its chiller tag in Utilities. Place an entity with PUT /api/entities/{entity_id}:

{ "equipment_id": 42, "labels": ["critical"] }

"equipment_id": null goes back to following the device. labels replaces the whole set. Both need entities.write, like every other registry field.

A label is a tag in the everyday sense: critical, line 1 utilities, calibrate quarterly. Devices and entities carry any number of them.

A label has a label_id and a name. The label_id is made from the name the first time (lowercase letters, digits and underscores, _2, _3 on a collision) and never changes, because it is what automations and screens refer to. The name can change whenever you like. You can also choose the label_id yourself when you create one.

Deleting a label takes it off everything carrying it.

Device labels are set with PUT /api/devices/{id}/labels, not through POST /api/devices. Every client that saves a device today sends the whole device without labels, and would wipe them on every save.

The equipment and label endpoints use snake_case field names, like the entity endpoints, because the tree is the entity model’s area registry and is shaped for the same consumers. The device’s new field is equipmentId, camelCase, like the rest of the device API.

Endpoint Permission What
GET /api/equipment equipment.read Every node, with its path of names from the root. Filter: ?level=
GET /api/equipment/{id} equipment.read {"equipment", "children", "devices", "entities"}: the node, the nodes directly under it, and what is placed directly on it
POST /api/equipment equipment.write {"parent_id", "level", "name", "description"}
PUT /api/equipment/{id} equipment.write Any of name, description, level, parent_id (null for the root). Answers {"equipment", "devices_rewritten"}
DELETE /api/equipment/{id} equipment.write Refused with 409 while anything is under it
GET /api/labels equipment.read Every label
GET /api/labels/{label_id} equipment.read {"label", "devices", "entities"} carrying it
POST /api/labels equipment.write {"label_id"?, "name", "color", "icon", "description"}
PUT /api/labels/{label_id} equipment.write Name, color, icon, description. Never the label_id
DELETE /api/labels/{label_id} equipment.write Removes it from everything
PUT /api/devices/{id}/labels equipment.write {"labels": [...]}, the whole set
POST /api/devices devices.write Gains the optional equipmentId, as above
GET /api/devices/{id} devices.read Gains labels next to device and tags

Every role holds equipment.read: where things are is what a device’s site and area already told a viewer. Only admin holds equipment.write, because renaming or moving one node rewrites the site, area and line of every device under it, and that is configuration.

Every change lands in the system log, category config, subject equipment:<id> or label:<label_id>.

list_devices and describe_device answer from the text, as they always did, and their output is unchanged. A test pins it to what the release before the tree answered, byte for byte, and then rearranges the tree around the device (an enterprise above its site, a cell below its line, a label) and checks nothing an agent sees moved. When the tree does change the text, a rename say, an agent sees the new text, the same as if somebody had typed it into the device. list_entities is where agents see the tree: it filters by a node and everything under it, and gives each entity’s place. See MCP.md.

equipment, labels, entity_labels and device_labels are in the backup, and so are the new equipment_id columns. equipment references itself (parent_id), which a restore cannot order: after a node moves, a parent can easily be stored after its child. The constraint is DEFERRABLE INITIALLY DEFERRED, so it is checked when the restore commits, once every row is in, and the same goes for anything else that rewrites the tree in more than one statement. The backup guard test allows a table to reference itself only when that constraint is deferred, and the round-trip test stores a child before its parent on purpose.

A backup taken before this version still restores. Its devices and entities files have one column fewer; the restore loads them into the table’s first columns (a new column is always added at the end) and the new one takes its default. Then, in the same transaction, it places every restored device on the tree by its text, the way the upgrade would have.

The tree, down the side, with a card for whichever node is picked: its path, the devices on it, the entities placed on it, and what is below it. An admin can add a node at the root or under the one picked, rename it, change its level, move it, delete it, place a device on it or move one off, and set a device’s labels. The level list offers only the levels the rules allow, both against the parent and against the children already below, and a move offers only the parents a node of that level can go under, never the node itself or anything below it. The server checks all of it again, and a refusal (a delete with something still under it, say) is shown exactly as it wrote it. Moving a device is the same save the device editor makes, with an equipmentId, so the text follows the tree as described above.

Labels are managed at the bottom of the screen: a name, a color and a description, with the label_id chosen once and fixed. Entities get their place and their labels from their drawer on the Entities screen.

Everyone else sees the same screen with nothing to press.