Skip to content
GitHub

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.

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.

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).

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”
  1. 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 -2 on 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.

  2. 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 name switch.kitchen_plug_relay from day one. It starts working the moment a person arms the point, and not before.

  3. 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 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.

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 number
  • the publisher sent no value (it sent None, null, nothing, or unknown)
  • 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.

Nothing is commanded until three things are true, in this order:

  1. 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.
  2. 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.”
  3. 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_replace is 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.

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.

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.

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>/+/+/config and <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.

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.

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 offline will 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 hotloop
    topic read homeassistant/+/+/config
    topic read homeassistant/+/+/+/config
    topic 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.

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.

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.

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.

Under /api/discovery. See API.md.

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.