Crystal MQTT

CI

An MQTT client for Crystal, supporting 3.1.1 and 5.0, with pluggable transports.

Installation

  1. Add the dependency to your shard.yml:

    dependencies:
      mqtt:
        github: spider-gazelle/crystal-mqtt
  2. Run shards install

Which client

| Class | Use it when | |---|---| | MQTT::Client | you don't know what the broker speaks. Tries 5.0, falls back to 3.1.1 | | MQTT::V5::Client | you want 5.0 features: properties, reason codes, topic aliases, enhanced auth | | MQTT::V3::Client | you are talking to a 3.1.1 broker and want nothing more |

All three share the same transports, reconnection, keep alive and timeout behaviour.

require "mqtt" brings in all of them; requiring a single client by path (require "mqtt/v5/client") pulls in only what it needs.

Usage

require "mqtt/v5/client"

transport = MQTT::Transport::TCP.new("test.mosquitto.org", 1883)
client = MQTT::V5::Client.new(transport)
client.connect

client.subscribe("sensors/#", qos: MQTT::QoS::BrokerReceived) do |topic, payload, retained|
  # payload is a Bytes slice, so binary payloads work
  puts "#{topic}: #{String.new(payload)}#{" (retained)" if retained}"
end

client.publish("sensors/kitchen", "21.5", qos: MQTT::QoS::BrokerReceived)

client.wait_close # blocks until the connection ends
client.disconnect

The 3.1.1 client is identical apart from the 5.0 specific arguments:

require "mqtt/v3/client"

client = MQTT::V3::Client.new(MQTT::Transport::TCP.new("test.mosquitto.org", 1883))
client.connect

TLS and websockets

tls = OpenSSL::SSL::Context::Client.new
MQTT::Transport::TCP.new("test.mosquitto.org", 8883, tls)
MQTT::Transport::Websocket.new("test.mosquitto.org", "/mqtt", 8081, tls)

Version negotiation

MQTT::Client connects as 5.0 and falls back to 3.1.1 when the broker will not take it. A broker that rejects the version closes the connection, so the retry needs a fresh transport — which is why this takes a factory:

require "mqtt/client"

client = MQTT::Client.new { MQTT::Transport::TCP.new("test.mosquitto.org", 1883) }

client.connect          # => MQTT::Version::V5
client.version          # whichever was agreed
client.v5.try &.reauthenticate  # 5.0 features via the underlying client

Negotiation happens once. The client that wins keeps the factory, so reconnects go straight back at the agreed version rather than re-probing 5.0 every time.

It exposes what the two protocols have in common — publish, subscribe, unsubscribe, ping, disconnect, subscriptions, wait_close. Anything version specific stays on #v5 or #v3.

Retained messages

A retained message is held by the broker, not the connection, so it outlives the client that published it and is delivered to whoever subscribes next.

# store the current state of a device
client.publish("state/kitchen", "on", qos: MQTT::QoS::BrokerReceived, retain: true)

# a later subscriber receives it immediately, flagged as retained
client.subscribe("state/#") do |topic, payload, retained|
  if retained
    apply_stored_state(topic, payload)   # this is the value from before we connected
  else
    handle_live_update(topic, payload)   # this just happened
  end
end

# a zero length retained publish clears it
client.publish("state/kitchen", "", qos: MQTT::QoS::BrokerReceived, retain: true)

The third block parameter is what lets you tell stored state from a live update. It is optional — a two parameter block still works.

5.0 adds control over whether retained messages are sent at all, and whether the publisher's retain flag survives to you:

client.subscribe("state/#",
  retain_handling: MQTT::V5::RetainHandling::Never,  # SendAlways, SendIfNew, Never
  retain_as_published: true
) { |topic, payload, retained| }

MQTT 5.0

Properties

Every 5.0 packet can carry properties. They are typed accessors on the packet, and only the ones legal for that packet exist:

client.publish("sensors/kitchen", %({"c":21.5}),
  qos: MQTT::QoS::BrokerReceived,
  content_type: "application/json",
  response_topic: "sensors/kitchen/reply",
  correlation_data: request_id,
  message_expiry_interval: 60_u32,
  payload_format_indicator: 1_u8,      # 1 == UTF-8
  user_properties: [{"tenant", "acme"}]
)

To read them, take the packet itself rather than topic and payload:

handler = ->(packet : MQTT::V5::Publish) do
  packet.content_type          # => "application/json"
  packet.user_properties       # => [{"tenant", "acme"}]
  packet.correlation_data
  nil
end
client.subscribe(["sensors/#"], handler, qos: MQTT::QoS::BrokerReceived)

Reason codes

Acknowledgements carry a reason in 5.0, so a broker can accept a packet and still reject the request. A failing PUBACK raises rather than resolving, and a failing PUBREC ends the QoS 2 exchange without sending PUBREL.

begin
  client.publish("some/topic", "payload", qos: MQTT::QoS::BrokerReceived)
rescue error : MQTT::ProtocolError
  # e.g. "publish rejected: quota exceeded"
end

SUBACK reason codes are per filter, so a partial rejection names which ones failed.

Subscription options

client.subscribe("sensors/#",
  qos: MQTT::QoS::BrokerReceived,
  no_local: true,              # don't echo our own publications back
  retain_as_published: true,   # keep the publisher's retain flag
  retain_handling: MQTT::V5::RetainHandling::SendIfNew,
  identifier: 42_u32           # tags delivered messages, cheaper than re-matching
) { |topic, payload| }

Negotiated limits

The client honours what the CONNACK negotiated, rather than being disconnected for exceeding it:

client.server_receive_maximum        # => 20
client.server_maximum_packet_size    # => 2000000
client.server_maximum_qos            # => MQTT::QoS::SubscribersReceived
client.server_retain_available?      # => true

Topic aliases

Established automatically when the broker offers them: the first publish to a topic carries both the topic and an alias, later ones send just the alias. They are scoped to a connection and reset when it drops.

client.use_topic_aliases = false # to always send the full topic

Enhanced authentication

The shard ships no SASL mechanism of its own — name the method the broker expects and supply the exchange:

client.authenticator = MQTT::V5::Authenticator.new("SCRAM-SHA-1") do |challenge|
  challenge.nil? ? initial_response : answer(challenge)
end

client.connect          # multi step challenges are answered during connect
client.reauthenticate   # re-authenticate an established connection

Server initiated disconnect

A 5.0 broker can say why before closing:

client.wait_close
client.disconnect_reason   # => MQTT::V5::ReasonCode::SessionTakenOver
client.server_reference    # => "other.broker:1883" on a redirect

Reason codes that mean "do not come back" (bad credentials, banned, redirected) veto an automatic reconnect.

Quality of service

All three levels are supported. publish blocks until the handshake completes.

| Level | Enum | Behaviour | |---|---|---| | 0 | MQTT::QoS::FireAndForget | returns once the packet has been written | | 1 | MQTT::QoS::BrokerReceived | waits for PUBACK | | 2 | MQTT::QoS::SubscribersReceived | waits for PUBREC, sends PUBREL, waits for PUBCOMP |

Inbound QoS 2 messages are held until the broker sends PUBREL, so a redelivery is never dispatched to your callback twice.

Reconnection

Pass a block that builds a transport and the client re-establishes the connection whenever it drops, replaying the CONNECT and restoring every subscription with its callbacks intact.

client = MQTT::V5::Client.new(reconnect: MQTT::Reconnect.new) do
  MQTT::Transport::TCP.new("test.mosquitto.org", 1883)
end

client.connect(client_id: "my-client")
client.subscribe("sensors/#") { |topic, payload| handle(topic, payload) }
# the subscription above survives a dropped connection

Delays back off exponentially and are capped:

MQTT::Reconnect.new(initial_delay: 1.second, max_delay: 30.seconds, max_attempts: nil)

If the broker reports session_present the subscriptions are already held server side and are not sent again. When reconnection is exhausted, or you call disconnect, the client is terminated? and wait_close returns.

Requests made while the connection is down fail with MQTT::NotConnectedError — messages are not queued for later delivery.

Keep alive

The client pings automatically whenever the link has been idle, at 75% of the negotiated interval, and closes the connection if the broker stops responding. A 5.0 broker may impose its own interval, which is honoured.

client.connect(keep_alive: 30)                        # seconds
client.connect(keep_alive: 30, keep_alive_active: false)  # ping yourself instead

Timeouts

Every request that waits on the broker takes a timeout, defaulting to 30 seconds. Set it to nil to wait indefinitely.

client = MQTT::V5::Client.new(transport, timeout: 5.seconds)
client.publish("some/topic", "hello", qos: MQTT::QoS::BrokerReceived, timeout: 1.second)

MQTT::Transport::TCP.new("test.mosquitto.org", read_timeout: 30, write_timeout: 10)

Transport lifecycle

Constructing a transport does not open a socket; the client connects it once its callbacks are in place, which is what stops data arriving before there is anything able to process it. A connection failure therefore surfaces from Client.new, not from the transport constructor.

transport = MQTT::Transport::TCP.new("test.mosquitto.org", 1883) # no socket yet
client = MQTT::V5::Client.new(transport)                         # connects here

Errors

Every error raised by this shard is an MQTT::Error, so a single rescue covers them:

| Error | Raised when | |---|---| | MQTT::TimeoutError | the broker did not respond in time | | MQTT::NotConnectedError | the transport closed, or was already closed | | MQTT::ConnectError | the broker refused the connection, carries return_code | | MQTT::SubscriptionError | the broker rejected a topic filter | | MQTT::ProtocolError | the broker sent something invalid, or rejected a request | | MQTT::PacketError | a packet could not be encoded |

Topic matching

MQTT.topic_matches?(filter, topic) implements the wildcard rules, including shared subscription ($share/group/...) prefixes. Per the specification, # and + at the first level do not match topics beginning with $ — subscribe to $SYS/# explicitly for broker system topics.

Limits

max_packet_size caps how large a single packet from the broker may be, defaulting to 8MB. A larger packet closes the connection rather than being buffered.

MQTT::V5::Client.new(transport, max_packet_size: 64_u32 * 1024)

Development

./test                        # whole suite, broker and all
./test spec/v5_live_spec.cr   # one file

./test finds a broker in order of preference: one you nominated with MQTT_LIVE_BROKER, a local mosquitto binary, then docker compose. It runs the formatter and ameba as well, and is what CI runs, so a green run locally means a green run there.

Without a broker the suite still runs; the end to end specs report as pending rather than silently passing.