Tutorials

Smart Home Topic Design: A Practical MQTT Layout

A practical MQTT topic layout for smart homes: rooms and devices, state vs command topics, retained flags, availability, Home Assistant discovery and ACLs.

Updated · 9 min read

Smart Home Topic Design: A Practical MQTT Layout

A good smart home MQTT topic layout is boring and predictable: one branch per room, one per device, and a fixed set of leaf topics for state, commands and availability. This article proposes a concrete layout you can copy, explains why commands and state live on separate topics, which messages to retain, and how the layout maps to Home Assistant discovery and broker ACLs.

The layout
home/<room>/<device>/state          current state, JSON, retained
home/<room>/<device>/set            commands to the device, NOT retained
home/<room>/<device>/availability   "online" / "offline", retained, Last Will
home/<room>/<device>/event          one-off events (button press), NOT retained

Naming rules

Topic names are part of your home's API. Changing them later means touching every automation, so decide the rules once:

  • Lowercase ASCII, words joined by -: living-room, ceiling-light. No spaces, no umlauts, no emoji.
  • No leading slash. /home/kitchen creates an empty first level and does not match home/#.
  • Never start with $: that prefix is reserved for broker topics such as $SYS.
  • Nouns for places and things, fixed words for leaves: state, set, availability, event. Do not mix status, cmd and command across devices.
  • Keep the depth constant. Every device sits exactly at home/<room>/<device>, so home/+/+/state always means "every device's state".

More general guidance is in MQTT topic design best practices.

Room-based or ID-based?

StyleExampleGood forTrade-off
Room-basedhome/kitchen/ceiling-light/stateTypical homes, hand-written automationsMoving a device changes its topics
ID-basedhome/device/shelly-a8032a/stateLarge or frequently changing installationsRoom must be kept elsewhere (metadata, HA areas)
Hybridhome/kitchen/shelly-a8032a/stateUnique names with readable groupingStill changes when moved

For most homes, room-based wins on readability. If you have devices that move (a portable heater, a sensor on a cart), give them a stable ID and put them under a neutral branch such as home/mobile/.

Commands vs state

The most important rule: the device publishes state, everyone else publishes commands, and they never share a topic.

One toggle, end to end
app      ──PUBLISH home/kitchen/ceiling-light/set   {"state":"ON"}            (not retained)
light    ──applies the command, then
light    ──PUBLISH home/kitchen/ceiling-light/state {"state":"ON","brightness":255}  (retained)
app, HA  ◀─ receive the new state and update the UI

Why separate them?

  • The state topic always reflects reality. If the command fails (relay stuck, device offline), the state topic still shows the truth, and the UI does not lie.
  • No echo loops. A device subscribed to its own state topic would receive its own updates. With set and state apart, each side only subscribes to what it needs.
  • Safe ACLs. A dashboard user can be allowed to write .../set and read .../state without being able to fake the device's state.

The device should publish the new state after applying the command, even if nothing changed. That confirmation is what UIs and automations wait for. For request/response patterns with correlation IDs, see command and control over MQTT.

What to retain

TopicRetain?Why
.../stateYesA dashboard that opens later shows current state immediately
.../availabilityYesNew subscribers know which devices are offline
.../setNoA retained command replays on every reconnect: ghost switching after a power cut
.../eventNo"Button pressed" is only meaningful at the moment it happens
homeassistant/.../configYesDiscovery configs must survive Home Assistant restarts

If a command was retained by mistake, clear it by publishing an empty retained message to the same topic. Details in retained messages.

Availability with Last Will

Each device sets a Last Will on its availability topic when it connects: payload offline, retained, QoS 1. Right after connecting it publishes online, retained. If the device loses power or Wi-Fi, the broker publishes offline once the keep-alive expires, about 1.5 × the keep-alive interval. See MQTT Last Will.

A device in 40 lines

This Python script (paho-mqtt 2.x) behaves like a dimmable light that follows the layout. The examples use a testmqtt/ prefix so they are safe to run on a shared test endpoint; in your house you drop it.

fake_light.py
import json, os
import paho.mqtt.client as mqtt

BASE = "testmqtt/home/kitchen/ceiling-light"
state = {"state": "OFF", "brightness": 0}

def publish_state(client):
    client.publish(f"{BASE}/state", json.dumps(state), qos=1, retain=True)

def on_connect(client, userdata, flags, reason_code, properties):
    client.publish(f"{BASE}/availability", "online", qos=1, retain=True)
    client.subscribe(f"{BASE}/set", qos=1)
    publish_state(client)

def on_message(client, userdata, msg):
    try:
        cmd = json.loads(msg.payload)
    except ValueError:
        cmd = {"state": msg.payload.decode(errors="replace").strip().upper()}
    if not isinstance(cmd, dict):
        cmd = {}
    if cmd.get("state") in ("ON", "OFF"):
        state["state"] = cmd["state"]
    if isinstance(cmd.get("brightness"), int):
        state["brightness"] = max(0, min(255, cmd["brightness"]))
    publish_state(client)  # always confirm with the real state

client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="kitchen-ceiling-light")
if os.getenv("MQTT_USER"):
    client.username_pw_set(os.environ["MQTT_USER"], os.getenv("MQTT_PASS"))
client.will_set(f"{BASE}/availability", "offline", qos=1, retain=True)
client.on_connect = on_connect
client.on_message = on_message
client.connect("broker.connectmq.com", 1883, keepalive=30)
client.loop_forever()

Publishing state in on_connect matters: after every reconnect the retained state is refreshed, and subscriptions are re-created even when the broker kept no session.

Test the layout

Watch every device's state and availability in one terminal:

Terminal 1 — observe
mosquitto_sub -h broker.connectmq.com -p 1883 -v \
  -t 'testmqtt/home/+/+/state' -t 'testmqtt/home/+/+/availability'

Send commands from another:

Terminal 2 — command
mosquitto_pub -h broker.connectmq.com -p 1883 -q 1 \
  -t 'testmqtt/home/kitchen/ceiling-light/set' -m '{"state":"ON","brightness":180}'

mosquitto_pub -h broker.connectmq.com -p 1883 -q 1 \
  -t 'testmqtt/home/kitchen/ceiling-light/set' -m 'off'

Then check the behaviours that the layout promises:

  1. Every command produces exactly one state message.
  2. Restart terminal 1: the current state and online appear immediately (retained).
  3. Kill the script with kill -9: after the keep-alive timeout, offline appears.
  4. Restart the script: no command is replayed, because set was never retained.

You can do the same in the browser with the online MQTT client, and check any subscription pattern against your topics with the topic matcher.

Useful subscriptions

NeedSubscription
Everything in the househome/#
Everything in the kitchenhome/kitchen/#
State of every devicehome/+/+/state
Which devices are offlinehome/+/+/availability
All button presseshome/+/+/event

Because the depth is constant, single-level + wildcards are enough and you rarely need # outside debugging. See MQTT wildcards in depth.

Home Assistant discovery

Home Assistant can create entities automatically from retained config messages under homeassistant/<component>/<object_id>/config. The layout maps directly onto the discovery fields:

homeassistant/switch/kitchen_ceiling_light/config (retained)
{
  "name": "Kitchen ceiling light",
  "unique_id": "kitchen_ceiling_light",
  "state_topic": "home/kitchen/ceiling-light/state",
  "value_template": "{{ value_json.state }}",
  "command_topic": "home/kitchen/ceiling-light/set",
  "payload_on": "ON",
  "payload_off": "OFF",
  "availability_topic": "home/kitchen/ceiling-light/availability"
}

payload_available and payload_not_available default to online and offline, which is exactly what the Last Will above publishes. For the full integration setup see Home Assistant and MQTT.

ACLs that follow the layout

A consistent tree makes permissions short:

ClientWriteRead
Each deviceits own state, availability, eventits own set
Home Assistant / automation hubhome/+/+/set, homeassistant/#home/#
Wall tablet dashboardhome/+/+/sethome/+/+/state, home/+/+/availability
Guest or monitoring usernothinghome/+/+/availability

One credential per device means a compromised smart plug can only lie about itself, not switch your front-door lock.

The layout costs nothing to adopt and pays off with every new device: constant depth, state and set split, retained state and availability, commands never retained. Write the rules down, test each new device against the four checks above, and your automations will keep working when the house grows.

Frequently asked questions

Should MQTT command topics be retained?

No. A retained command is replayed to the device every time it reconnects, so a light can switch on by itself after a power cut. Retain state and availability; send commands without the retain flag.

Should the room be part of the topic?

For a typical home, yes: home/kitchen/ceiling-light reads well and lets you subscribe to a whole room with home/kitchen/#. The downside is that moving a device changes its topics. Larger installations often use stable device IDs and keep the room as metadata instead.

One JSON state topic or one topic per property?

A single JSON state message per device is easier to keep consistent and is what Home Assistant's JSON templates expect. Per-property topics are simpler for tiny sensors and some tools. Pick one style and use it for every device.