Memory
Architecture decision record 0001 accepted

Use MQTT as the device transport

Device state and commands move over a broker rather than over HTTP request/response between the platform and each device.

  • Transport
  • Devices
  • MQTT

3 min read · 590 words

Context01

The forces at the time

The platform needs to command and observe a growing number of small networked devices — ESP32 lighting controllers first, others later. The devices sit on a residential network, take their addresses from DHCP, reboot without warning, and lose power as a group.

The first design had the platform hold an inventory of device addresses and issue HTTP requests to each one. With a single device this was adequate. Extending it exposed four problems at once: addresses had to be pinned by hand, the platform became responsible for retry and availability logic, simultaneous changes arrived as a visible stagger across devices, and devices had no way to report state without being asked.

These are properties of request/response with an unknown, intermittently-connected set of receivers, not defects in the implementation. Fixing them individually means building a message broker inside the platform, one workaround at a time.

Decision02

What was chosen

Adopt MQTT as the transport for device communication. Run a broker as a platform service. Devices connect outbound to the broker and subscribe to the topics that concern them; the platform publishes to topics and never addresses a device directly.

Topic structure is part of this decision, not an implementation detail left to each module. Topics run general to specific, and command topics are separated from reported-state topics so that no device can retrigger itself by observing its own output.

text
nexus/<domain>/<zone>/set          # command,  QoS 1, not retained
nexus/<domain>/<zone>/state        # reported, QoS 1, retained
nexus/<domain>/<zone>/available    # LWT,      QoS 1, retained

Reported state and availability are retained so a subscriber learns current state on connect. Commands are never retained, because a retained command is replayed to every device that later subscribes. Availability is backed by a Last Will registered at connect, so an unclean disconnect is published by the broker as an explicit event.

Commands use QoS 1 and handlers are required to be idempotent. QoS 2 is not used.

Alternatives03

What else was considered

HTTP endpoint per device
Rejected. Requires stable addressing, a device inventory in the platform, and per-device retry logic; produces a visible stagger on group changes; gives devices no way to report state unprompted.
Devices poll the platform
Rejected. Removes the addressing problem but trades it for latency proportional to the poll interval, and constant traffic from every device whether or not anything has changed.
WebSocket connection per device
Rejected. Solves bidirectionality and gives push, but the platform is then responsible for connection state, fan-out, and reconnection for every device — which is a broker, written by hand and less well tested.
MQTT with QoS 2 throughout
Rejected. A four-step handshake and per-message broker state to achieve exactly-once, when the commands in question are naturally idempotent and QoS 1 is sufficient.
Consequences04

What follows

Gained

  • Adding a device requires no change on the publishing side.
  • A group change is one publish, so devices act simultaneously.
  • Devices report state and availability without being polled.
  • No inbound addressing or port forwarding to any device.
  • Device failure is an explicit retained event rather than an inference from silence.

Paid for

  • The broker is a platform service to run, secure, and keep alive, and a single point of failure for all device traffic.
  • Debugging is harder: a message delivered to nobody is indistinguishable from one delivered to everybody without a subscriber tool in hand.
  • Request/response interactions need a correlation pattern built on top, or a separate HTTP path.
  • Topic names are unvalidated. A typo creates a live topic that silently receives messages nothing reads.

Records are immutable once accepted. A change supersedes this one rather than editing it.