Tutorials

CircuitPython MQTT with MiniMQTT

Publish and subscribe from CircuitPython boards with Adafruit MiniMQTT: settings.toml secrets, TLS on ESP32-S2/S3 and Pico W, callbacks, loop() and reconnects.

Updated · 8 min read

CircuitPython MQTT with MiniMQTT

Adafruit MiniMQTT (adafruit_minimqtt) is the MQTT client for CircuitPython. It runs on Wi-Fi boards like the ESP32-S2, ESP32-S3 and Raspberry Pi Pico W, uses the board's built-in TLS stack, and follows a callback style that will feel familiar if you have used paho-mqtt. This tutorial installs it, connects securely, publishes a reading every 10 seconds, reacts to commands and survives Wi-Fi drops.

The examples target CircuitPython 9.x with a current MiniMQTT release from the Adafruit bundle. Because CircuitPython reloads code.py each time you save, you can iterate quickly — and you will also see why a deployed device needs a real error-handling loop.

What you need

ItemNotes
CircuitPython board with Wi-FiESP32-S2/S3 Feather or QT Py, Pico W, or an AirLift board
CircuitPython 9.xFlash from circuitpython.org for your exact board
adafruit_minimqtt libraryPlus its dependencies, installed by circup
An MQTT brokerExamples use broker.connectmq.com (8883 TLS)

Install the library with circup, which copies the right .mpy files and dependencies to the CIRCUITPY drive:

Install MiniMQTT
pip install circup
circup install adafruit_minimqtt
circup list          # shows installed versions, flags outdated ones

You can also copy the adafruit_minimqtt folder from the library bundle into lib/ by hand; circup is less error-prone because it matches the bundle to your CircuitPython version.

1. Secrets in settings.toml

CircuitPython reads settings.toml from the root of the drive. The two CIRCUITPY_WIFI_* keys also make the board join Wi-Fi automatically at boot.

settings.toml
CIRCUITPY_WIFI_SSID = "your-ssid"
CIRCUITPY_WIFI_PASSWORD = "your-wifi-password"
MQTT_BROKER = "broker.connectmq.com"
MQTT_PORT = 8883
MQTT_USER = "username"
MQTT_PASS = "password"

Read them with os.getenv(). Keeping credentials here means code.py can be shared or committed without leaking anything.

2. A complete publisher and subscriber

code.py
import os, time
import microcontroller, ssl, socketpool, wifi
import adafruit_minimqtt.adafruit_minimqtt as MQTT

if not wifi.radio.connected:
    wifi.radio.connect(os.getenv("CIRCUITPY_WIFI_SSID"),
                       os.getenv("CIRCUITPY_WIFI_PASSWORD"))
print("Wi-Fi:", wifi.radio.ipv4_address)

DEVICE = "cpy-" + microcontroller.cpu.uid.hex()
BASE = f"testmqtt/cpy/{DEVICE}"

def on_connect(client, userdata, flags, rc):
    print("connected, rc =", rc)
    client.subscribe(f"{BASE}/cmd", qos=1)

def on_disconnect(client, userdata, rc):
    print("disconnected, rc =", rc)

def on_message(client, topic, message):
    print(f"{topic}: {message}")
    if message == "ping":
        client.publish(f"{BASE}/pong", str(time.monotonic()))

pool = socketpool.SocketPool(wifi.radio)
mqtt = MQTT.MQTT(
    broker=os.getenv("MQTT_BROKER"),
    port=os.getenv("MQTT_PORT"),
    username=os.getenv("MQTT_USER"),
    password=os.getenv("MQTT_PASS"),
    client_id=DEVICE,
    is_ssl=True,
    keep_alive=60,
    socket_pool=pool,
    ssl_context=ssl.create_default_context(),
)
mqtt.on_connect = on_connect
mqtt.on_disconnect = on_disconnect
mqtt.on_message = on_message
mqtt.will_set(f"{BASE}/status", "offline", retain=True, qos=1)

mqtt.connect()
mqtt.publish(f"{BASE}/status", "online", retain=True, qos=1)

last = 0
while True:
    mqtt.loop(timeout=1)                  # reads incoming packets, sends PINGREQ when due
    if time.monotonic() - last >= 10:
        temp = microcontroller.cpu.temperature
        mqtt.publish(f"{BASE}/temperature", f"{temp:.1f}")
        last = time.monotonic()

What the important lines do:

  • socket_pool and ssl_context connect MiniMQTT to the board's network stack. ssl.create_default_context() uses the root certificates built into the CircuitPython firmware, so a broker with a certificate from a public CA verifies without extra files.
  • loop(timeout=1) must run often. It processes incoming messages, calls on_message and sends keep-alive pings on schedule. The timeout must not be smaller than the client's socket_timeout (1 second by default), or MiniMQTT raises an error.
  • Subscribing in on_connect means every successful connection — the first one and every reconnect — restores the subscription. This matters because shared test endpoints like broker.connectmq.com do not keep sessions between connections.
  • microcontroller.cpu.uid gives a stable, unique client ID. If you prefer random IDs for throwaway tests, the client ID generator creates valid ones.
  • microcontroller.cpu.temperature is a convenient demo value; on some boards it returns None. Swap in a real sensor driver (BME280, SHT4x, …) for actual readings.

Routing per topic

When a board subscribes to several topics, per-topic callbacks keep the code tidy:

Per-topic callbacks
def on_led(client, topic, message):
    led.value = message == "on"

mqtt.add_topic_callback(f"{BASE}/led", on_led)
mqtt.subscribe(f"{BASE}/led")

Messages on that topic go to on_led; anything else still reaches the general on_message.

3. Test it from the browser

Save code.py and open the serial console (Mu, Thonny or screen /dev/tty.usbmodem* 115200). You should see the IP address, then connected, rc = 0.

Now open the online MQTT client, connect to the same broker and subscribe to testmqtt/cpy/#. You will see the retained online status and a temperature every 10 seconds. Publish ping to testmqtt/cpy/<device>/cmd and a pong comes back within a second.

Watch your CircuitPython board in the browser

To test the Last Will, unplug the board: after roughly one and a half keep-alive periods, the broker publishes offline to the status topic.

4. Surviving disconnects

In development, an exception simply stops code.py and you fix it. On a device in a cupboard, an uncaught exception means it stays silent until someone power-cycles it. Wrap the loop:

code.py — resilient main loop
def ensure_wifi():
    if not wifi.radio.connected:
        print("Wi-Fi reconnect")
        wifi.radio.connect(os.getenv("CIRCUITPY_WIFI_SSID"),
                           os.getenv("CIRCUITPY_WIFI_PASSWORD"))

delay = 1
last = 0
while True:
    try:
        mqtt.loop(timeout=1)
        if time.monotonic() - last >= 10:
            mqtt.publish(f"{BASE}/temperature", f"{microcontroller.cpu.temperature:.1f}")
            last = time.monotonic()
        delay = 1
    except (OSError, RuntimeError, ConnectionError, MQTT.MMQTTException) as e:
        print("error:", e, "- retry in", delay, "s")
        time.sleep(delay)
        delay = min(delay * 2, 60)
        try:
            ensure_wifi()
            mqtt.reconnect()              # on_connect runs again and resubscribes
            mqtt.publish(f"{BASE}/status", "online", retain=True, qos=1)
        except Exception as e2:
            print("reconnect failed:", e2)
            if delay >= 60:
                microcontroller.reset()   # last resort after repeated failures

The pattern is the same on every platform: fix the network layer first, then MQTT, back off exponentially so a fleet does not stampede a restarting broker, and keep a hard reset as the final safety net. MQTT reconnect best practices explains the reasoning in more depth.

5. TLS and certificate notes

  • Public CA certificates just work. CircuitPython ships a root certificate bundle, and ssl.create_default_context() uses it.
  • Self-signed or private CA? Load it explicitly: ctx = ssl.create_default_context(); ctx.load_verify_locations(cadata=CA_PEM) where CA_PEM is the PEM text of your root certificate.
  • Memory is limited. TLS needs a sizable buffer. On boards without PSRAM, connect to MQTT early in code.py and avoid large allocations before the handshake.
  • Plain TCP on 1883 works with is_ssl=False and port=1883, but sends the password in clear text. Use it only on a bench network.

If the handshake fails, rule out the broker side first with a TLS connection test from your computer.

Errors you will see

MessageCause
ConnectionError: No network with that ssidWrong SSID, or a 5 GHz-only network
ConnectionError: Authentication failureWrong Wi-Fi password
MMQTTException: Connection Refused - Incorrect username/passwordBroker rejected the credentials (CONNACK 4)
MMQTTException: Connection Refused - UnauthorizedNot allowed to connect (CONNACK 5)
OSError: [Errno 116] ETIMEDOUTPort blocked, wrong host, or broker unreachable
MemoryErrorNot enough heap for TLS or a large payload

The CONNACK codes are the standard MQTT 3.1.1 return codes; the reason codes tool lists them all. For a sensor build that goes further — multiple sensors, JSON payloads, a dashboard — see the MQTT weather station. And if you are choosing between platforms, the MicroPython umqtt tutorial shows the same tasks with a lower-level client.

MiniMQTT gives CircuitPython boards a complete MQTT client with TLS in a few dozen lines. Put the secrets in settings.toml, subscribe in on_connect, call loop() constantly and wrap it all in a reconnect loop, and a hobby board becomes a device you can leave running for months.

Frequently asked questions

Which boards can run Adafruit MiniMQTT?

Any CircuitPython board with a network stack: boards with native Wi-Fi such as ESP32-S2, ESP32-S3 and the Raspberry Pi Pico W, and boards that use an ESP32 AirLift co-processor through the esp32spi library. The MQTT code is the same; only the socket pool setup differs.

Does MiniMQTT support MQTT 5 or QoS 2?

No. MiniMQTT speaks MQTT 3.1.1 and supports QoS 0 and QoS 1. Brokers that support MQTT 5 accept 3.1.1 clients too, so it works with them, but you cannot use MQTT 5 properties from the board.

Why are my MiniMQTT messages strings instead of bytes?

By default MiniMQTT decodes payloads as UTF-8 and passes a str to on_message. For binary payloads such as CBOR or images, create the client with use_binary_mode=True and you will receive bytes instead.