TasmotaPubSub
TasmotaPubSub is Tasmota's hardened fork of Nick O'Leary's
PubSubClient, for ESP8266 and ESP32.
It preserves the PubSubClient class and established method signatures while adding a bounded
MQTT 5 profile, MQTT 3.1.1 compatibility, stricter packet validation, QoS state tracking, and
Tasmota command request/response support. Instances own buffers and replay state and therefore
cannot be copied.
The local mqtt-v5.0.md file is the protocol reference. This README describes the profile
implemented by this library; it does not claim support for every optional MQTT 5 feature.
Protocol selection and compatibility
PubSubClient.h is a small compile-time facade. It imports the Tasmota configuration,
defines the two accepted MQTT_VERSION values, and includes exactly one implementation
header. When MQTT_VERSION is not defined, the library defaults to MQTT 3.1.1.
The selector must be defined project-wide because the sketch/application and both library sources are separate translation units. For PlatformIO, use a build flag:
build_flags =
-DMQTT_VERSION=MQTT_VERSION_5_0
Tasmota may instead define MQTT_VERSION in user_config_override.h, which is imported
through tasmota_options.h. Use MQTT_VERSION_3_1_1 for an explicit level-4-only build.
The implementation files are split by profile:
src/PubSubClient_311.handsrc/PubSubClient_311.cppcontain the restored compact MQTT 3.1.1 implementation.src/PubSubClient_5.handsrc/PubSubClient_5.cppcontain the MQTT 5 implementation.
Arduino and PlatformIO compile both .cpp files. Each source has a whole-file selector guard,
so only the selected implementation contributes definitions. Only the two values above are
accepted when MQTT_VERSION is defined.
An MQTT5-capable PubSubClient instance starts at protocol level 5. It permanently selects
level 4 for that instance only after explicit unsupported-version evidence:
- a valid MQTT 5 CONNACK with Reason Code
0x84; or - the exact MQTT 3.1.1 refusal
20 02 00 01.
Transport failures, timeouts, malformed packets, and other refusal codes do not cause a
downgrade. The fresh level-4 attempt uses a new transport connection, and incompatible
MQTT5 session state is discarded before it. protocolVersion() reports the effective level.
A dedicated MQTT 3.1.1 build contains no fallback classifier or runtime selection path.
The dedicated MQTT 3.1.1 profile intentionally stays close to the restored implementation, including its compact API, one-to-four-byte Remaining Length support, and established CONNECT credential encoding. MQTT 5-only APIs and state are confined to the MQTT 5 profile.
Implemented MQTT 5 profile
Framing and validation
- MQTT Variable Byte Integers are deliberately limited to two bytes (
0..16,383). A packet outside that bound is refused before transmission or rejected without an out-of-bounds read. - Packet and Properties lengths are checked against bytes actually received.
- Fixed-header flags, packet direction/state, Packet Identifiers, packet-specific Reason Codes, and property legality/multiplicity are validated before callbacks or state mutation.
- CONNECT and CONNACK handling is transactional: no partial packet or partially validated negotiated/session state is committed.
- Partial transport writes are terminal because the MQTT byte stream can no longer be trusted.
- A protocol error sends an MQTT5 DISCONNECT Reason Code when the socket is still writable, then closes the transport. Transport failures close silently.
disconnect()aborts the transport by default;disconnect(true)sends the normal short DISCONNECT packet first.
Broker limits and connection state
The client applies Server Keep Alive and Receive Maximum, and uses Topic Alias Maximum for opt-in outbound aliases. Broker Maximum Packet Size is enforced for PUBLISH, subscription, acknowledgement, and replay packets, but not currently for PINGREQ or DISCONNECT. Negotiated values are per connection and reset at connection boundaries. CONNACK Maximum QoS, Retain Available, Wildcard Subscription Available, Subscription Identifier Available, and Shared Subscription Available values are range-validated but not retained or enforced.
Useful diagnostics include sessionPresent(), connackReasonCode(), lastError(),
lastReasonCode(), lastPacketId(), and serverDisconnectReasonCode(). CONNACK outcome and
MQTT5 properties are meaningful only while the effective protocol is MQTT 5; the level-4-only
and runtime-fallback paths do not latch level-4 CONNACK details.
Define USE_MQTT_DETAILED_LOGGING in a Tasmota MQTT5 build to trace outbound PUBLISH packets
and inbound messages at DEBUG_MORE, with JSON-safe topic, payload, and property fields. Each
PUBLISH is logged as MQT: Tx(<qos>) <topic> | <payload> | <properties>, with the QoS level
always shown in parentheses so the line parses uniformly for every QoS. Inbound messages are
logged as MQT: Rx <topic> | <payload> | <properties>. Only PUBLISH is traced (not
PINGREQ/SUBSCRIBE/ACK/DISCONNECT). Both directions are suppressed after an instance falls back
to MQTT 3.1.1, and all related logging code is compile-time excluded when the switch is absent.
A persistent connection (cleanSession == false) uses an MQTT5 Session Expiry Interval,
configurable with setSessionExpiryInterval() and defaulting to 0xFFFFFFFF. Local resumable
state is bound to endpoint, port, Client Identifier, effective protocol, and session policy;
it is never replayed to a different identity. Session Present is accepted only when compatible
local state exists. Eligible unacknowledged PUBLISH/PUBREL state is replayed in deterministic
order, and connection establishment fails on the first replay framing or transport failure.
PUBLISH, QoS, and Packet Identifiers
Outbound QoS 1/2 acknowledged delivery is an opt-in feature, compiled in only when
USE_MQTT_QOS is defined. It is off by default because Tasmota publishes at QoS 0 in
practice, and leaving it out removes the replay buffers, retransmission, PUBACK/PUBREC/PUBCOMP
handling, and the Receive Maximum back-pressure gate for a smaller flash and RAM footprint.
Without USE_MQTT_QOS, a publish requested at QoS 1 or 2 is refused before framing (returns
false), exactly as the MQTT 3.1.1 fallback refuses it. Everything below in this subsection
applies only when USE_MQTT_QOS is compiled in; QoS 0 publishing, subscriptions, PUBLISH
properties, and inbound QoS 0/1/2 delivery are unaffected by the switch.
The established publishing methods remain QoS 0. With USE_MQTT_QOS, MQTT5-capable code can
additionally use:
MqttPublishRequest request;
request.topic = "tele/device/STATE";
request.payload = payload;
request.plength = payload_len;
request.qos = 1; // 0, 1, or 2 in effective MQTT 5 mode
request.retained = false;
request.properties.contentType = "application/json";
MqttClient.publish(request);
QoS 1/2 PUBLISH, SUBSCRIBE, and UNSUBSCRIBE share one per-client Packet Identifier namespace. Acknowledgements must match both the identifier and expected operation state. A local ACK timeout is diagnostic only: it does not release the identifier or operation.
Buffered QoS 1/2 publishing reserves the Packet Identifier, outstanding slot, and complete
replay body before the first wire byte. The body must fit within MQTT_INFLIGHT_COPY_BUDGET
(default 2048 bytes); failure refuses the publish without leaking those resources. This atomic
guarantee does not include optional topic-alias table updates, so aliases remain disabled by
default. Streaming remains QoS 0 because replayable bytes do not exist after they are written.
When broker Receive Maximum credit is exhausted, buffered QoS 1/2 publish() may wait up to
MQTT_ACK_TIMEOUT while calling loop(). That can dispatch inbound callbacks reentrantly; a
zero timeout refuses immediately.
beginPublishWithProperties() is the MQTT5-only QoS-0 streaming form. It consumes property
pointers while assembling the header; payload bytes can then be sent with the usual
write()/endPublish() sequence without fitting the complete payload in the working buffer.
Only one streaming publish may be active, and exactly the declared payload length must be
written; a short or excess stream is terminal because it desynchronizes the connection.
PUBLISH properties
MqttMessageProperties supports Payload Format Indicator, Content Type, Response Topic,
Correlation Data, User Properties, and opt-in outbound Topic Alias use. Response Topic and
Correlation Data are independent: a request can carry only a Response Topic, while a response
normally publishes to that topic and carries only the copied Correlation Data.
Inbound properties are available inside the message callback through inboundProperties(),
forEachInboundProperty(), and forEachInboundUserProperty(). String and User Property views
borrow the client's single working buffer and are valid only until the next inbound packet is
parsed. Copy values that must survive the callback. Inbound Correlation Data uses fixed
client-owned storage, but that storage is reused by the next inbound PUBLISH.
Inbound Correlation Data is limited to 16 bytes. A larger value is a protocol error and is never truncated. Outbound Correlation Data has no special 16-byte limit; normal buffer, broker Maximum Packet Size, and Remaining Length limits apply.
Topic Alias support is outbound-only; inbound alias-only PUBLISH reuse is not implemented.
Outbound aliases are disabled by default (MQTT_MAX_TOPIC_ALIASES == 0). When enabled, the
table is bounded by that compile-time count and the broker's Topic Alias Maximum. Alias state is
committed during header assembly, before QoS replay retention, so do not enable aliases for
QoS 1/2 without accounting for a later retention failure.
The legacy inbound setStream() path is not MQTT5 property-aware: it begins streaming after the
Topic Name and Packet Identifier, before MQTT5 Properties are parsed. Do not use inbound stream
mode with effective MQTT 5; use the bounded callback path instead.
Deliberately unsupported features
The selected profile does not implement AUTH or Enhanced Authentication. Authentication Method
and Authentication Data are rejected as protocol errors. Subscription Identifier and Message
Expiry properties can be parsed through forEachInboundProperty() and logged by Tasmota, but
they are not configured outbound or acted upon. Will Delay is parsed only as a legal property;
it has no application behavior. New semantics must preserve bounded memory, transactional
framing, and the dedicated MQTT 3.1.1 build.
Tasmota integration
The core integration is in tasmota/tasmota_xdrv_driver/xdrv_02_9_mqtt.ino and is compiled only
when MQTT_VERSION == MQTT_VERSION_5_0.
For an inbound MQTT5 command carrying Response Topic:
MqttDataHandlervalidates the Response Topic (non-empty, no embedded NUL or+/#, shorter thanTOPSZ) and copies it into an ownedString. Propagation activates only if allocation and the complete copy succeed. Optional Correlation Data is copied into 16-byte storage.- A scoped
MqttCommandContextpointer is attached toXdrvMailboxacrossFUNC_MQTT_DATA,CommandHandler, native command dispatch, and synchronous nested commands. - The scope saves/restores the previous pointer, so early returns and nested MQTT callbacks do not leak one request into another. Backlog, timers, and other later asynchronous work do not inherit the context.
- Each publication routed through the standard
MqttPublishLibfunnel keeps its normal Tasmota publication unchanged and attempts a separate, non-retained PUBLISH to the Response Topic. This remains two publications even if both topic strings are equal, because the normal state publication may be retained while a request response must not be. Direct Azure-method and AWS shadowMqttClient.publish()calls bypass this behavior. Correlated delivery is best effort: failure is logged, while the wrapper returns the normal publication's result. - The response copies Correlation Data when present and does not repeat Response Topic as a property. Correlation Data without a valid Response Topic does not activate propagation.
This preserves existing stat/... state/result behavior while enabling MQTT5 request/response,
including multipart commands. No payload type is inferred: callers should add Payload Format or
Content Type only when they can guarantee those semantics. Arbitrary inbound User Properties are
not echoed automatically.
Berry API
The Berry mqtt module is the scripting front-end to this integration:
mqtt.publish(topic, payload [, retain, start, len, loglevel])— QoS 0 publish (also exposed astasmota.publish). Runs the local rule engine on string payloads.mqtt.subscribe(topic, closure)— subscribe at QoS 0 and route matching messages toclosure(topic, idx, payload_str, payload_bytes). Registered topics are re-subscribed automatically on reconnect.mqtt.unsubscribe(topic)— unsubscribe fromtopic, or from every registered topic whentopicisnil.mqtt.connected() -> boolandmqtt.protocol() -> int(effective level,5or4).
MQTT 5 request/response, used inside a subscription closure while a request is being handled:
mqtt.is_request() -> bool— true when the inbound message carried a Response Topic, i.e. it expects a reply. Always false on an MQTT 3.1.1 broker.mqtt.respond(payload) -> bool— reply to that request. Publishespayloadto the request's Response Topic, echoing its Correlation Data, QoS 0 and non-retained. The topic and correlation are taken from the inbound message; the caller supplies only the payload. Returnstrueif a response was sent,falsewhen there is no active request (seemqtt.is_request()), on MQTT 3.1.1, or on a bad payload argument.mqtt.metadata() -> map— inbound MQTT 5 properties of the message being handled:qos,retain, and, when present,response_topicandcorrelation_data.mqtt.last_error() -> map— MQTT 5 diagnostics:error,reason_code,packet_id,server_disconnect_reason.
def ping(topic, idx, payload_s, payload_b)
if mqtt.is_request()
mqtt.respond("pong") # replies to the request's Response Topic, echoes Correlation Data
end
end
mqtt.subscribe("device/ping", ping)
mqtt.respond() reuses the same scoped request context as the automatic command response above,
but is an explicit, standalone reply for Berry subscription handlers. It publishes through the
non-recursing single-publish, so it does not itself re-trigger the automatic correlated response.
It is intentionally QoS 0 and never retained: a reply is a one-shot correlated message that must
not be stored by the broker for later subscribers of the Response Topic. The outbound Berry
property surface is limited to this request/response use; general User Properties, Content Type,
and Payload Format Indicator are not exposed to Berry even though the library supports them on the
wire.
Resource model and concurrency
Important defaults are a 1200-byte working packet buffer and, for subscription tracking, twelve
tracked-operation entries (MQTT_MAX_PENDING_SUBSCRIPTIONS). When USE_MQTT_QOS is compiled
in, the tracked-operation table grows by MQTT_MAX_INFLIGHT (four) publish slots to sixteen
entries, each slot gains the Retained_Packet_Body replay fields, and the client adds a
2048-byte replay-copy budget; subscriptions and publishes then share that combined table, so
either can reduce capacity available to the other. Eight inbound QoS2 identifiers exist
regardless, because inbound delivery is independent of the outbound QoS switch. Owned library
strings use Arduino String; library sources avoid Standard Library string/container types.
Connection, buffer, callback, protocol, QoS, and session state are per PubSubClient instance.
Multiple brokers are supported by using one instance and one underlying Client/socket per
broker and servicing every instance's loop(). This is cooperative multi-instance support, not
thread safety. Do not call one instance concurrently; on ESP8266, MQTT5 property lookup also uses
a shared non-reentrant scratch row, so externally serialize different instances as well.
Host validation
The sanitizer-enabled doctest harness is under tests/:
cd tests
make check # authoritative selector, source scan, MQTT 3.1.1, MQTT5, and no-QoS gate
make baseline # frozen MQTT 3.1.1 behavior
make mqtt5 # mandatory MQTT5 suite (USE_MQTT_QOS enabled)
make noqos # MQTT 5.0 built without USE_MQTT_QOS (QoS-0-only production config)
make test # full run of both protocol binaries
See tests/README.md for individual targets and sanitizer controls. Protocol changes should pass
make check and representative ESP8266/ESP32 firmware builds.
License
Released under the MIT License. See LICENSE.txt.