MQTT discovery
Point a device at HotLoop’s broker and it shows up. Its sensors read, its switches show their state, its entities have the names, units and classes it announced. Nobody types a tag list.
And it can’t command a damn thing. Every point discovery makes is read-only, and it stays that way until an admin accepts the device and then arms each point by hand, looking at the exact topic the command will go to. A device announcing itself is a device telling you what it is. That’s useful. It is not a device earning the right to flip a relay on your plant, and HotLoop doesn’t pretend it is.
What speaks it
Section titled “What speaks it”The format is the one with topics like homeassistant/sensor/kitchen/power/config.
It carries the name of the project that defined it; HotLoop speaks it because
the publishers do, and that’s the whole relationship.
| Publisher | How it gets here | What we test it against |
|---|---|---|
ESPHome (its mqtt: component) |
per component, on by default, retained | recorded output of real nodes (see below) |
| Zigbee2MQTT | per component, with templates on nearly everything | recorded output of real installs |
| Tasmota | its own format, below | recorded output of nine real devices |
| Shelly (Gen2, Gen3, Gen4) | no announcement at all; learned from the statuses it publishes, below | recorded output of fifteen sets of real messages and the maker’s documentation |
| Anything else that publishes the format | the same | the format’s own rules |
About ESPHome: the plan was to run a real ESPHome firmware in CI, built for the
host platform. It doesn’t work. ESPHome 2026.9.1’s mqtt: component is only
available on ESP32, ESP8266, BK72xx, LN882x and RTL87xx, and esphome config on
a host build says exactly that and stops. So ESPHome is held to recordings of
real nodes, the same as Zigbee2MQTT, which won’t start without a Zigbee radio.
The recordings are in internal/discovery/testdata. Every file is messages a
real install published, captured by the person running it and posted in a
public issue, headed with the publisher and version, the device, the link, and
exactly what was changed (almost always nothing but JSON whitespace). They are
what CI checks every config, every template and every state payload against.
Tasmota
Section titled “Tasmota”Current Tasmota doesn’t publish the homeassistant/ format. Its docs say that
was removed from every build. What it publishes, retained, is its own:
tasmota/discovery/<MAC>/config: the device (its topic, its full-topic pattern, its relays and lights, its on and off words, its last-will words);tasmota/discovery/<MAC>/sensors: what its sensors read right then.
Both are written by TasDiscovery in Tasmota’s xdrv_12_discovery.ino (read at
arendst/Tasmota development 5c295fb, 2026-10-12). HotLoop turns the two into
the same components every other publisher makes, so everything after (the
Discovered screen, accepting, arming, the write gate, the reading) is one code
path. The MAC is the device’s identity; a device that is also announcing in the
homeassistant/ format (an old firmware with SetOption19) is the same device.
| Tasmota says | HotLoop makes |
|---|---|
a relay (rl 1) |
a switch: reads stat/<topic>/POWER (POWER1, POWER2 and so on when there’s more than one), in the device’s own on and off words; commands cmnd/<topic>/POWER[n] with ON or OFF |
a light (rl 2) |
a light: on and off the same way. With one dimmer, its level is the Dimmer key of tele/<topic>/STATE, 0 to 100, and is set with cmnd/<topic>/Dimmer |
| every number in the sensors message | a sensor each, read from tele/<topic>/SENSOR by its path (ENERGY Power, SCD40 CarbonDioxide), with the unit and class Tasmota gives that name. A three-phase meter’s array is one sensor per phase |
| its last will | availability: tele/<topic>/LWT, with the device’s own Online and Offline words |
Topics are built the way Tasmota builds them: its full topic with %prefix%
and %topic% filled in, so a device whose owner changed the full topic
(tasmota/35D7A0/%prefix%/, or bi/test/%topic%/%prefix%) reads and commands
on the topics it actually uses.
What it doesn’t read, it says, in the source’s problems and never by silence: shutters, a switch input, a button, an iFan, a camera, the colors of a color light, and the brightness of a device with more than one light. Nothing in the recordings shows any of those working, so nothing here claims to read them.
Asking. Tasmota reports a relay when it changes and everything every five
minutes. So when a device starts being read, HotLoop sends one empty message to
cmnd/<topic>/POWER, which Tasmota answers with the state of every relay and
moves nothing; without it a device that hasn’t changed reads unknown for up to
five minutes after HotLoop starts. The message is empty, to a topic that ends
in POWER and nothing else, and a listen-only source sends nothing at all.
A light’s brightness arrives with Tasmota’s telemetry (every five minutes
unless the device’s TelePeriod is shorter), not the moment it changes. On
and off arrive at once.
On each broker the format is a switch (tasmota on the broker, in
Settings, MQTT). A broker added now has it on; the embedded broker always has
it; a broker that already existed keeps listening exactly as before, so it’s
off until somebody turns it on. Off means HotLoop doesn’t even subscribe to
tasmota/discovery/# there.
A config is untrusted like any other. One published under a MAC that isn’t
the one it says it is, a full topic with no %topic% or with a wildcard, a
topic with a slash in it, an on word with a quote in it, a version it doesn’t
know: each is refused with why, and nothing is made. A sensor name with a
quote in it never reaches a template.
What this was tested against. Nine devices’ real messages, captured from
Tasmota’s own console and posted in its public issue tracker, in
internal/discovery/testdata/tasmota, each headed with the link, the version
and what was changed (nothing but whitespace, and a redacted MAC):
| Recording | Device | Source |
|---|---|---|
| esp32-8relay-13.4.0 | ESP32 board, 8 relays, Tasmota 13.4.0 | github.com/arendst/Tasmota/issues/20953 |
| esp32-zigbee-gateway-reboots-15.3.0.1 | ESP32 with no relays, three boots | github.com/arendst/Tasmota/issues/24487 |
| esp8266-sps30-scd40-airsensor-14.1.0 | CO2 and particulate sensor, 14.1.0 | github.com/arendst/Tasmota/issues/24780 |
| esp8266-sps30-scd40-airsensor-15.4.0-invalid-json | the same box on 15.4.0, whose firmware published JSON with a trailing comma | the same issue |
| nous-a1t-energy-plug-14.5.0 | NOUS A1T plug with energy metering | github.com/arendst/Tasmota/issues/23094 |
| olimex-esp32-poe-counter-custom-fulltopic-14.5.0 | counters and a three-phase meter, full topic changed | github.com/arendst/Tasmota/issues/23775 |
| shelly-dimmer2-15.3.0.3 | Shelly Dimmer 2 running Tasmota | github.com/arendst/Tasmota/issues/24560 |
| tuyamcu-energy-meter-custom-fulltopic-14.5.0 | TuyaMCU energy meter, full topic changed | github.com/arendst/Tasmota/issues/23165 |
| wemos-2relay-15.1.0.1 | two relays, from Tasmota’s author’s own console log | github.com/arendst/Tasmota/issues/24006 |
These are recordings, not a device on the bench. A Tasmota on the lab bench
isn’t here yet, so this is verified against recorded output of real devices
and not against one running now. The things the recordings don’t have are the
brightness (the dimmer’s own tele/STATE line was garbled in its source log;
its Dimmer key is in the same recording’s STATUS11, which Tasmota builds
with the same function) and the power query being answered (the recordings
show the question and the answer, from Tasmota’s console, not HotLoop asking).
Shelly
Section titled “Shelly”A Shelly Gen2, Gen3 or Gen4 announces nothing. There is no config topic to wait
for. What it does, once MQTT is on and its status notifications are on, is
publish each component’s whole status on <prefix>/status/<component>
(switch:0, cover:0, light:0, input:0, a virtual boolean:200) every time it changes, and its last will on <prefix>/online.
So HotLoop learns the device from what it says: the components it has
published, the fields each one has carried, and the MAC from its sys status.
The prefix is the device’s id by default (shellyplus2pm-485519968144), which
is its model and its MAC, so a device on its default prefix doesn’t even wait
for sys. One on a prefix somebody typed (ShellyLichtGarten) has to publish
sys before it can be told apart from another one, and until it does it is
named in the source’s problems and not made.
What was learned is kept, per prefix, and only ever grows. A component that goes quiet hasn’t been removed, and a status with less in it than the last one takes nothing away, so a restart doesn’t make a device lose parts. Everything after that is the same path as every other publisher: the Discovered screen, accepting, arming each point, the write gate, the reading.
| Shelly says | HotLoop makes |
|---|---|
switch:n with an output |
a switch: reads output from <prefix>/status/switch:n; commands <prefix>/command/switch:n with on or off |
light:n |
a light the same way, with set,true and set,false; with brightness in its status, a level 0 to 100, sent as set,true,<level> |
cover:n |
a cover: reads state (open, closed, opening, closing, stopped) and sends open, close and stop. A cover that says pos_control is calibrated, so it also reads current_pos and is sent pos,<n>; one that isn’t only opens and closes |
apower, voltage, current, freq, pf, aenergy.total, temperature.tC on a switch, light or cover |
a sensor each, in W, V, A, Hz, Wh and degrees C, with the class Shelly’s documentation gives them |
input:n |
a binary sensor of its state |
devicepower:n, voltmeter:n, illuminance:n, presencezone:n |
sensors and binary sensors of battery, external power, voltage, light level, presence and the number of objects |
boolean:n, number:n, enum:n (virtual components) |
read-only sensors. Setting a virtual component is an RPC call, which this doesn’t do, and each one is said in the source’s problems |
its online last will |
availability, with Shelly’s own true and false |
The commands are the ones the maker’s MQTT page lists under MQTT Control, and
the tests hold HotLoop to that list: on, off and toggle for a switch;
set,<bool>,<brightness> for a light; open, close, stop and pos,<n> for
a cover. Every one is a write through the gate, armed by a person.
Asking. A Shelly publishes a component when it changes, so a device that
hasn’t changed would read unknown until it does. When a device starts being
read, HotLoop sends status_update to <prefix>/command, which the same page
says makes the device publish its components’ status and moves nothing. It is
that one word, to a topic that ends in /command and nothing else, and a
listen-only source sends nothing at all. It happens each time the device’s
reading starts, so arming several points in a row asks several times.
What the device has to have on. MQTT enabled, with the broker’s address;
status notifications on (status_ntf); and, for commands, control on
(enable_control). HotLoop’s own broker has a shelly login profile, named
after the device’s prefix, that lets it publish its statuses and hear
<prefix>/command and nothing else.
What it doesn’t read. Said, in the source’s problems, never by silence: a
component of a kind it hasn’t been shown (lora:100 on a Gen4 with the LoRa
add-on, a pm1, an em), a switch or a cover that has left out the field it is
read from, and a virtual component’s setting. Input events (a double push, a
long push) arrive on the RPC notification topic, <prefix>/events/rpc, and
aren’t read; only an input’s state is. A prefix with more than two levels
(home/garage/boiler) isn’t heard; one or two are. Nothing is sent over RPC.
On each broker it’s a switch (shelly on the broker, in Settings, MQTT).
A broker added now has it on; the embedded broker always has it; a broker that
already existed keeps listening exactly as before, so it’s off until somebody
turns it on. It is a switch because the topics it listens on, +/status/+, are
wide, and a broker that carries something else under */status/* wants it off.
Off means HotLoop doesn’t subscribe to them there.
It’s untrusted like any other topic. A prefix that isn’t letters, digits and
_.-, a component that isn’t kind:number, a payload that isn’t an object or
is over 64 KiB, a device that says more than 64 components, and more than 512
devices being learned at once are each ignored, and a structure kept under one
prefix that says it is another is refused.
What this was tested against. Fifteen sets of real messages and the maker’s
documentation, in internal/discovery/testdata/shelly. The device recordings
are mostly the debug logs of the ioBroker Shelly adapter, posted in its public
issue tracker (one comes from another project’s), each headed with the link, the device,
the firmware where the post gives one and what was changed (whitespace). The
documentation files are the maker’s own examples, fetched on 2026-10-02 and
2026-10-12.
| Recording | Device | Source |
|---|---|---|
| plus-1-fw1.4.2-switch-rpc | Plus 1, firmware 1.4.2 | github.com/iobroker-community-adapters/ioBroker.shelly/issues/1055 |
| plus-2pm-cover-mode | Plus 2PM as a roller shutter, calibrated | the same repository, issues/1088 |
| 1pm-mini-gen3-fw2.0.0-switch | 1PM Mini Gen3, 2.0.0 | the same, issues/1592 |
| 2pm-gen3-fw2.0.1-beta3-switch | 2PM Gen3, 2.0.1-beta3, two channels | the same, issues/1627 |
| 2pm-gen4-lora-addon | 2PM Gen4 with the LoRa add-on | the same, issues/1349 |
| plug-s-gen3-fw2.0.1-beta1-switch | Plug S Gen3 | the same, issues/1594 |
| pro4pm-gen2-fw2.0.1-switch-set | Pro 4PM, 2.0.1, with the poster’s MAC redacted | the same, issues/1640 |
| pro-dimmer-2pm-fw1.3.0-light | Pro Dimmer 2PM | the same, issues/974 |
| dimmer-gen3-light-sys | Dimmer Gen3 | github.com/enesbcs/shellyteacher4domo/issues/65 |
| pill-gen3-fw1.8.99 | Pill, 1.8.99, with a voltmeter | the ioBroker repository, issues/1232 |
| presence-gen4-fw2.1.99-dev | Presence Gen4, three zones | the same, issues/1228 |
| frankever-sprinkler-virtual-components | a sprinkler controller on a prefix of its own, virtual components | the same, issues/1207 |
| official-docs-component-status | the maker’s component examples: Plus 1PM, Plus 2PM, H&T, Pro 1, WDUS | shelly-api-docs.shelly.cloud |
| official-docs-mqtt-control | the maker’s MQTT Control commands | the same |
| official-docs-mqtt-page, official-docs-notifications | the maker’s MQTT page and notification examples | the same |
Two of them can’t be built into a device, and the tests say so: the Pro 4PM’s
prefix has the poster’s redaction in it (xxxx) and no sys, and the garden
light’s is a prefix of its own with none either. Both wait for their MAC, as a
real one would.
This is verified against recordings and the documentation, and not against a
Shelly on the bench. None is here yet. The statuses are what real devices
published, the topics HotLoop reads are topics those devices used, and the
commands are in the forms the maker documents. What the recordings can’t show:
a command actually moving a relay, a cover actually going to a position, and
the status_update answer. When a Shelly is on the bench, those are the three
things to try first.
What happens when something announces itself
Section titled “What happens when something announces itself”-
It appears. On the embedded broker, a device that announces itself is a HotLoop device straight away: protocol
mqtt, not placed on the tree, named after what it called itself (kitchen-plug, then-2on a collision), reading. Its tags are named after what it announced (power,relay). Its entities get the name, device class, state class, unit, category and icon the config gave them, and an entity the config says is off by default starts disabled. -
Nothing is armed. Every tag is created read-only. A switch, a number or a text gets its control entity at once, unarmed (
armed: false), so a screen or an automation can nameswitch.kitchen_plug_relayfrom day one. It starts working the moment a person arms the point, and not before. -
An admin decides. Devices, Discovered. Who announced it (on the embedded broker, the login and client id that published its config), every component it would make, every topic it reads, every command topic in full, and what to look at:
- a command topic outside the topics the component reads (a “lamp” whose
commands go to
cmnd/boiler/POWER); - a command topic inside the discovery prefix, or one another device also claims;
- a config that asks for retained commands, because a retained command replays every time the device reconnects;
- templates HotLoop can’t evaluate, and config keys the format doesn’t define.
Accept keeps it reading and makes its points armable, one at a time, in the tag editor. It arms nothing. If the device suggested an area and a tree node has exactly that name, the accept dialog pre-fills it. A publisher doesn’t get to file itself under Boiler Room; a person clicks accept. Ignore drops the device, tags and entities (history stays, like any device’s) and keeps it dropped when it announces again. Deleting a discovered device is the same as ignoring it, because one that keeps announcing would just come back.
- a command topic outside the topics the component reads (a “lamp” whose
commands go to
When it changes its mind
Section titled “When it changes its mind”A different login is held for review, whatever it says. On the embedded broker, a device remembers the login it was accepted from. The same device announced by another login is held for review with the two logins side by side, even when the config is word for word the same, and the log gets a warning. Either somebody moved the device to a new login, or a login is announcing a device that isn’t its own. Apply, and the new login is the one it’s accepted from. Reject, and that login announcing the same thing stays rejected.
A config that changes under an accepted device is held for review. The accepted config keeps running. The Discovered screen shows the change field by field, running against announced, and an admin applies or rejects it. A unit going from °C to °F, or a state topic pointed somewhere else, is exactly how a wrong number gets a trusted face. Applying a change never changes whether a point is armed: that was a person’s decision and stays one.
A firmware update, a new device name, a new model or a different icon applies by itself. Nobody should have to click for a version bump.
Before anybody accepts a device, the newest announcement always applies: nobody has built on it yet.
An empty config withdraws a component. Its points read bad, saying “withdrawn by the publisher at 14:02”, and nothing is deleted. An alarm on that point keeps its target and says why it’s bad. If the same component comes back, it’s reinstated, and until it sends a new value it reads “announced again after a withdrawal; waiting for its next value” rather than passing off the value from before it left. A device nobody accepted that withdraws every component it had is gone, because nobody built on it.
Zigbee2MQTT renaming a device clears its old config topics and announces new ones with the same unique IDs. HotLoop keys components on the unique ID, so that’s a withdrawal and a reinstatement a second apart, and nothing is lost.
Reading
Section titled “Reading”A state goes through the component’s value template, if it has one, and then
through the component’s own matching: payload_on/payload_off for a binary
sensor (ON/OFF by default), state_on/state_off for a switch, a number for
a number, one of the options for a select. Anything that doesn’t fit is a bad
reading with the reason, shown on the tile:
"CLOSE" is neither payload_on "CLOSED" nor payload_off "OPEN"value_json has no key 'temperature'"unavailable" is not a numberthe publisher sent no value(it sentNone,null, nothing, orunknown)no update for 300 s (expire_after)
The first one is real: Zigbee2MQTT 1.41.0 announces an Avatto TRV07’s window sensor that way, and the person who captured it opened an issue because the window never showed. HotLoop shows it bad and says why instead of guessing.
Availability is the device’s link, not a reading. While the device says it’s offline on its availability topic, it shows disconnected with its own words (“the device says it is offline (plug/status = offline)”) and its entities are unavailable. Until the first availability message arrives, a device with availability configured isn’t available. That’s the format’s rule, and it’s the right one. The broker session isn’t torn down for it: it’s shared with every other device on that broker.
expire_after makes a value bad after that many seconds without an update.
off_delay turns a binary sensor off after that many seconds, because the
config says the device never sends the off. JSON attributes land on the entity,
up to 64 keys and 8 KiB, under HotLoop’s own (friendly_name, source,
armed and the rest always win).
A component with no state topic reads bad forever. HotLoop never assumes a state from a command.
Commands
Section titled “Commands”Nothing is commanded until three things are true, in this order:
- A person accepted the device. Until then the tag editor won’t arm a point on it (“accept the device first”), and the write gate refuses its commands on its own, so a hand-edited row or an old backup with the point armed gets nowhere either.
- A person armed the point, in the tag editor, one at a time. Before the
checkbox, the editor says where the commands go and what they send:
“Commands go to
plug/relay/set, sending ON or OFF.” - The write gate lets the command through, like every other write: writes switched on, the MCP switch for an agent, the tag’s range, the entity’s own checks, a live session. Audited with its call id, refused or not. The dry run asks exactly the same questions.
What goes out is what the config says: payload_on and payload_off for a
switch, payload_press for a button, the option for a select, the value for a
number or a text (a whole number as one, 5 not 5.0), through the config’s
command template when it has one. A button presses once. Its “release” would be a
second command the device never asked for, so a discovered button is single
shot.
A light gets payload_on/payload_off (a JSON-schema light, {"state":"ON"}),
and a brightness in percent scaled to its own brightness_scale and rounded,
on its brightness command topic (a JSON-schema light, {"state":"ON", "brightness":128}). A cover gets payload_open, payload_close or
payload_stop, and a position scaled from percent into its own
position_closed..position_open through set_position_template. A valve
gets its open and close payloads (a valve that reports a position, its
position_open and position_closed), a position on its command topic, and
payload_stop once: like a button, a discovered valve’s stop is single shot.
A payload the config switches off with null (a cover with no stop) is a
command the gate refuses with why.
The gate refuses a command, with the reason, when:
- nobody has accepted the device;
- the broker it’s on is listen-only;
- the publisher withdrew the component;
- its command template is outside the subset (“command template not
supported: filter
regex_replaceis not supported at column 12”). It is never “send the raw value instead”; - its config has no command topic;
- its config has no payload for what was asked: a stop on a cover whose
config says
payload_stop: null, an option a select doesn’t have, a brightness above 100%. The payload is rendered in the check, so the dry run says no too.
Retained commands follow the config. If a config asks for retain: true,
HotLoop publishes that component’s commands retained, and says so everywhere a
person decides about it: the Discovered screen flags it and the tag editor
says it in plain words. Understand what that means before you arm one: the
broker keeps the last command and hands it to the device every time the device
reconnects, at three in the morning, with nobody’s hand on anything. The
command still went through the gate and is still on the audit trail with a name
against it; it just happens again.
There is no “publish this to that topic” anywhere: not in automations, not in scripts, not in MCP. That would be a side door round the gate with a nicer name. In the code, two things in discovery publish: the driver’s write, which only the gate reaches, and the birth. A guard test type-checks the driver package and fails the build if anything else calls either, with a planted bypass to prove it bites.
Templates
Section titled “Templates”No Python, no Jinja engine. internal/mqtttpl is a hand-written subset of Jinja
sized to what publishers actually send: lookups, a conditional, a dict used as a
lookup table, a few filters. No loops, so a template costs what its length
costs.
{% set name = value %} is in, and only that shape: Zigbee2MQTT writes every
thermostat’s mode as {% set values = {'manual':'heat', ...} %} and then looks
it up, and a thermostat whose mode can’t be read is a thermostat you can’t
trust. No namespace, no unpacking, no block form. .keys() is in too, only as
the right side of in, because that’s the one place those templates use it.
| Details | Details |
|---|---|
| Structure | text, {{ expr }}, {% if %} / {% elif %} / {% else %}, {% set name = value %}, {# comments #}, - whitespace control |
| Variables | value templates: value, value_json (only when the payload is JSON), entity_id, name; command templates: value, entity_id, name |
| Expressions | literals, .attr, ['key'], [0], slices, + - * / // % **, ~, comparisons, and or not, in, x if c else y, .get(key, default) on a mapping, in mapping.keys() |
| Tests | defined, undefined, none, number, integer, float, boolean, string, mapping, sequence, true, false, eq, ne, lt, le, gt, ge, in, odd, even, divisibleby |
| Filters | float, int, round, bool, default/d, string, lower, upper, capitalize, title, trim, replace, truncate, abs, length/count, min, max, tojson, to_json, from_json, is_defined, iif |
| Functions | float(), int(), bool(), iif(), min(), max() |
Where the format documents a filter differently from stock Jinja, HotLoop
follows the format. The one that matters: float and int without a default
fail on something that isn’t a number. Stock Jinja quietly hands back 0, and
that’s the zero that says the tank is empty when the sensor stopped answering.
Values render the way Python’s str() renders them, because that’s what every
publisher’s configs were written against: True, None, 21.0 stays 21.0.
It isn’t cosmetic. Zigbee2MQTT’s contact sensor announces payload_on: false
and matches the rendered False.
A missing key is an error unless you handled it. {{ value_json.temperature }}
on a payload without one is a bad reading saying so, not an empty string.
default, is defined and a bare {% if value_json.x %} handle it on purpose,
and are fine.
Outside the subset, refused when the config arrives, with where: “value
template not supported: filter regex_findall_index is not supported at column
12”. The component is still made, so you can see it exists, and it reads bad
with that reason. There’s no best-effort partial render and no “use the raw
payload instead”. Two real templates are refused on purpose: now() (a value
that depends on when HotLoop evaluated it isn’t a reading of the device), and a
Zigbee2MQTT event template that builds regex patterns with namespaces and
regex_findall.
Limits: 4 KiB of template, nesting 32 deep, 64 KiB of output, 64-bit integers (an overflow is an error, not a wrap).
How it’s checked: Pallets Jinja2 3.1.6 runs in a throwaway container in CI, and
every case in the oracle battery (grammar, operators, truthiness, whitespace
control, str() rendering) has to come out the same as it does there. The
format’s own filters, which aren’t stock Jinja, have hand-written cases citing
the documented behavior each one holds us to. Every template in the
recordings has to parse. The parser and the evaluator are fuzzed.
What HotLoop builds from what
Section titled “What HotLoop builds from what”| Component | Tag | Entities |
|---|---|---|
sensor |
float when it has a unit, a state class or a numeric device class, else string |
sensor |
binary_sensor |
bool |
binary_sensor |
switch |
bool |
binary_sensor, and a switch control entity, unarmed |
number |
float, with the config’s min/max as the tag’s range |
sensor, and a number control entity, unarmed, with min, max and step |
text |
string |
sensor, and a text control entity, unarmed |
select |
string, reading only one of its options |
sensor, and a select with the config’s options, unarmed |
button |
string, taking a press and reading nothing |
a single-shot button, unarmed (the tag’s own read entity is made hidden) |
light |
bool on/off; float brightness 0 to 100 % when it has one |
a light, unarmed |
cover |
string state, which also takes open, close and stop; float position 0 to 100 % when it has one |
a cover, unarmed |
valve |
string state; float position 0 to 100 % if it reports one; bool command (open or close) and bool stop, commanded and never read |
a valve, unarmed, its stop single shot |
event |
string, the event type |
an event |
device_automation (a trigger) |
string, <type>_<subtype> when its payload arrives |
an event, one per trigger |
lock |
string, read in its own words and taking lock, unlock, open |
a lock, unarmed |
fan |
bool on/off; float speed 0 to 100 %; string preset and direction; bool oscillation, each when it has one |
a fan, unarmed |
siren |
bool |
a siren, unarmed |
scene |
bool, a press |
a single-shot button, unarmed |
notify |
string, a message, commanded and never read |
a notifier, unarmed |
update |
string installed and latest versions |
an update: on when they differ. Its install command is never sent |
device_tracker |
string, home, not_home or a zone |
a tracker |
tag |
string, the tag’s id when one is scanned |
an event |
climate |
string mode; float setpoint, low and high setpoints and humidity setpoint, each with the config’s min_temp/max_temp (or min_humidity/max_humidity) as its range; float room temperature and humidity; string fan, swing and preset modes and action; bool power, commanded and never read; each when it has one |
a thermostat, unarmed |
humidifier |
bool on/off; float humidity setpoint in its range; string mode and action; float room humidity |
a humidifier, unarmed |
water_heater |
string mode; float setpoint in its range and current temperature; bool power, commanded and never read |
a water heater, unarmed |
alarm_control_panel |
string state, read in the format’s words and taking the actions its supported_features list, each its own payload |
an alarm panel, unarmed |
vacuum |
string state, taking the actions its supported_features list; float battery; string fan speed, each from its JSON state |
a vacuum, unarmed |
lawn_mower |
string activity; bool start mowing, pause and dock, each a press on its own topic, commanded and never read |
a mower, unarmed |
camera |
string, a short hash of the latest picture; the picture itself is held in memory |
a camera |
image |
the same, for a picture on image_topic; or string, the URL from url_topic |
an image |
For a light, a cover, a valve, an event, a trigger and every component in the rows after them, the tags’ own read entities are made hidden: the thing is one entity on the screens. Each tag still gets armed on its own, so a cover whose position you trust and whose open and close you don’t can be exactly that. A thing that gains a value after it was accepted (a firmware that starts reporting a brightness) is a change for review, and when a person applies it the entity gets the role along with the tag.
A JSON-schema light’s state messages are folded together, because the schema
sends what changed: a message with only a brightness, or Zigbee2MQTT’s
last_seen alone, leaves the light on. Its color, color temperature and
effect are shown as attributes, not commanded; on, off and brightness are,
and the Discovered screen says so for a light that has color. A default-schema
light’s color topics are the same: not read, not commanded, and listed. The
template schema isn’t supported and says so. Cover tilt isn’t read or
commanded, and says so.
A device trigger fires only on its own payload: Zigbee2MQTT puts every
action of a button on one topic, and each trigger is one of them. A trigger
with no payload fires on anything on its topic.
A fan’s speed reads on the config’s own speed_range_min..speed_range_max
the way the format does: the first step above off is the first percent. A
level the config says can’t happen (an ESPHome fan announcing four steps and
reporting 25) is a bad reading saying so, not 625 percent.
The Discovered screen lists every topic a component’s commands can go to: a light’s brightness topic, a fan’s speed topic, as well as the main one, and flags each that’s outside what the component reads, inside the prefix, or claimed by another device.
A thermostat’s or a water heater’s setpoint range is the config’s own, and the
format’s default when it gives none (7 to 35 °C for a thermostat, 43.3 to 60 °C
for a water heater, in °F when the config says temperature_unit: F). It’s the
tag’s range, so the gate refuses 31 °C to a radiator valve that tops out at 30
before anything is sent. Its modes are the config’s list, and a mode it doesn’t
list is refused the same way. The screens offer exactly those, and when an
applied change brings a new mode, the screens get it too.
An alarm panel’s code is what a consumer’s screen asks for before it sends
anything. In HotLoop, who may arm or disarm is the gate’s call, with the tag
armed by a person and every command audited, so the action goes out as the
config says. A panel whose command template wants the code itself ({{ code }})
can’t be commanded: HotLoop has no code to put there, and the Discovered
screen says so. A vacuum’s send_command_topic isn’t used: an arbitrary command is
exactly what HotLoop can’t check against the config.
A camera’s or an image’s picture is held in memory, the latest one per
component, up to 4 MiB, and served by GET /api/entities/{entity_id}/image.
HotLoop keeps it only if the bytes are a JPEG, PNG, GIF or WebP, whatever the
config’s content_type claims: an SVG is a document that runs script, and a
“picture” that runs script on whoever opens the dashboard is a hole. Its tag
holds a short hash, so the history says when the picture changed without a
database full of JPEGs. An image announced by URL is a link: HotLoop shows it,
only if it’s http or https, and never fetches it, because a server that
fetches whatever address a device names can be pointed at anything on the
network. Neither is logged in the logbook; a camera changes every second and
tag_history already has every change.
Every component the format defines becomes something. A component it doesn’t
define (a kettle) is stored with what it announced and listed as making
nothing. The format has no date, time or datetime component, so neither does
this list.
Entity IDs are HotLoop’s, made from the device and the tag. A config’s
object_id and default_entity_id are ignored: a config doesn’t get to pick a
name in HotLoop’s namespace, and a hostile one would pick switch.boiler_main.
Untrusted, all of it
Section titled “Untrusted, all of it”Everything a config says came from whoever can publish on the prefix. So:
- names and text are capped and stripped of control characters, and the screen puts them on the page as text, never HTML;
- a topic with a wildcard, a
$topic, or a~with no base to put there is refused with the reason; - a config over 64 KiB is refused; a device can’t have more than 512 components; no more than 256 devices can wait for a decision on one broker; config messages past 200 a second are dropped and counted. Every refusal is counted and shown on the Discovered screen, never dropped without a word;
- HotLoop subscribes to
<prefix>/+/+/configand<prefix>/+/+/+/config, never#under the prefix, so a stray deep topic can’t make it parse anything; - a state payload over 256 KiB, or one that isn’t UTF-8, is a bad reading.
With anonymous connections allowed on the embedded broker, anyone on the network can announce devices and publish to any command topic, and the Discovered screen says so in red. Give each device its own login made in HotLoop, from its profile, named after its topic (BROKER.md): then it can announce itself and nothing else, and it can’t touch another device’s command topic.
The broker side
Section titled “The broker side”Discovery runs on the embedded broker (see BROKER.md), on by
default whenever the broker is. HotLoop’s own client logs in with a login made
at every start and held only in memory: there’s nothing to configure and
nothing in a backup to leak. It publishes one thing of its own, the birth
(<prefix>/status = online), which makes publishers that listen for it
announce again.
| Variable | Default | Details |
|---|---|---|
HOTLOOP_BROKER_DISCOVERY |
true |
Listen for announcements on the embedded broker |
HOTLOOP_BROKER_DISCOVERY_PREFIX |
homeassistant |
The topic prefix they arrive under |
One client per broker, hotloop-disc-embedded, shared by the listener and every
discovered device’s driver. An --mcp-stdio helper opens none: a second client
with the same id would kick the Gateway’s off the broker every few seconds. With
several replicas on one database, only the one holding the discovery lock
listens.
Other brokers
Section titled “Other brokers”Plenty of plants already run a broker: a Mosquitto that’s the UNS, the one a Zigbee2MQTT box brought with it. HotLoop listens to those too. Settings, MQTT, add a broker: an id (it names the broker in every device that came from it), where it is, a login, and the prefix its publishers announce under. The broker doesn’t have to be on, and HotLoop doesn’t need the embedded one to do this.
Three things are different from HotLoop’s own broker, on purpose:
-
Devices wait in the inbox. On a busy plant broker, “show up on their own” means three hundred devices on the screen before lunch. So an announced device is listed on the Discovered screen, nothing made and nothing reading, until somebody accepts it. Then it’s made, it reads, and its points can be armed like any other. A broker you trust to dump everything on you can be switched to “devices appear on their own”.
-
No last will. HotLoop connects without one. An
offlinewill on a shared broker would tell every other publisher on it that something went away, when it was only HotLoop. The tests check the CONNECT packet itself, and pull the plug on the connection to prove nothing gets said. -
Its ACLs are its own. HotLoop can’t see who published what on somebody else’s broker, so there’s no “announced by login” there, and what each login may touch is that broker’s business. Give HotLoop a login that reads the prefix and the topics of the devices you accept, and writes the birth and the command topics, nothing else. For Mosquitto:
user hotlooptopic read homeassistant/+/+/configtopic read homeassistant/+/+/+/configtopic write homeassistant/status# the devices you accept, for example a Zigbee2MQTT bridge:topic read zigbee2mqtt/#topic write zigbee2mqtt/+/set
Listen-only is for a broker HotLoop must not speak on: the production UNS,
somebody else’s plant. HotLoop never publishes anything there. No birth, no
command. That’s the session’s rule, not the screen’s: its publish refuses, and
the write gate refuses every command to its devices with “the broker
plant-uns is listen-only”, dry run included, even on a device somebody accepted and a point
somebody armed. Its login needs nothing but topic read lines.
The password is sealed with the device credential key
(HOTLOOP_SECRET_KEY_FILE), like a UniFi console’s, and never comes back out
of the API. Without a key a broker with a password is refused, not stored in
the clear. A backup carries it sealed and restores under the same rule as a
device’s credentials (BACKUP.md). A broker whose password can’t be
opened is listed, not connected, and says why.
Change a broker and the listener and every device from it connect again with the new settings. Switch one off and its devices stay, reading bad and saying their broker isn’t running here. Removing one takes the inbox with it, and is refused, naming them, while devices somebody accepted from it still exist.
On an Edge Relay
Section titled “On an Edge Relay”An Edge Relay discovers too, with the same code, but it has no database to keep a decision in and no screen to make one on. So it takes whatever it hears, reads it and forwards it over Sparkplug, and nothing it finds can be armed: its tags are read-only like every discovered tag, its devices are never accepted, and the relay has no write path at all. Name the brokers in its device file; see EDGE-RELAY.md. A device you can command gets there the other way: accepted and armed on a Gateway, in a file it pushes. It forgets what it heard when it stops, and hears it again from the broker.
Restarts and replays
Section titled “Restarts and replays”Accepted devices run from their device rows. HotLoop doesn’t need a single retained message to come back up with its plant.
The embedded broker keeps nothing across a restart, so every config is “missing” afterwards. A missing config is not a withdrawal: only an explicit empty one is. Anything else would withdraw the whole plant every time the box reboots. Until a device sends its next state, its entities read unknown, never the value from before the restart dressed up as current.
A broker that replays hundreds of retained configs at once is one batch: two seconds of quiet, one pass, one reload. A replay of what’s already on record changes nothing and reloads nothing.
Backups
Section titled “Backups”mqtt_devices (who accepted and ignored what), mqtt_configs (every config as
it arrived, byte for byte) and mqtt_discovered (each component, normalised)
are in every backup. The decisions are the part no device can announce back.
The API
Section titled “The API”Under /api/discovery. See API.md.
Found on the network
Section titled “Found on the network”The same screen lists devices HotLoop heard on the plant network over mDNS, SSDP and DHCP, decided about the same way. See NETWORK-DISCOVERY.md.