MQTT & Wi-Fi: the bench becomes a network
420-302-VA · WEEK 9 · FALL 2026

Stage 5 of 6 · Lab · about 25 minutes, plus the ladder

The Pi listens

mosquitto_sub proves messages flow; Python makes them mean something. The Pi-side client is paho-mqtt, the Eclipse Foundation's reference library, and its shape will feel familiar on arrival: you hand it callbacks and start a loop, when_pressed for the network age. This is the page where Week 6's quiet promise about objects and callbacks gets kept in full.

Install: the venv habit pays again

paho-mqtt comes from PyPI, and Week 4 already taught the only correct way onto a modern Pi, inside the project's virtual environment, never sudo pip:

you@lastname-pi:~/iot-labs $ source .venv/bin/activate
(.venv) you@lastname-pi:~/iot-labs $ pip install paho-mqtt

paho-mqtt: the callback API

A minimal, complete subscriber, current (2.x) API:

# monitor_min.py: print every light reading as it arrives
import paho.mqtt.client as mqtt

BROKER = "localhost"              # the broker is this very Pi
TOPIC  = "bench/s07/light"

def on_connect(client, userdata, flags, reason_code, properties):
    print("Connected:", reason_code)
    client.subscribe(TOPIC)       # subscribe HERE, not outside: see below

def on_message(client, userdata, msg):
    pct = float(msg.payload.decode())
    print(f"{msg.topic}: {pct:.1f} %")

client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
client.on_connect = on_connect
client.on_message = on_message
client.connect(BROKER, 1883)
client.loop_forever()

Read it as the event-driven program it is, then keep four facts:

  • loop_forever() is the engine. Callbacks fire only while a network loop runs; a script that assigns callbacks and exits hears nothing, this week's version of Week 4's "the program must stay alive for when_pressed to matter".
  • Subscribe inside on_connect. Subscriptions die with the connection; paho reconnects automatically after drops, and on_connect re-fires each time, re-planting the subscription. Subscribed outside, a monitor survives its first disconnection as a zombie: connected, silent.
  • Payloads arrive as bytes. msg.payload.decode() mirrors the node's .encode(): the two ends of one contract, and forgetting it prints b'42.3', this week's most popular bug.
  • The version flag matters. CallbackAPIVersion.VERSION2 selects the current callback signatures. Most tutorials you will find online predate it (1.x: on_connect(client, userdata, flags, rc)), and pasting their code raises signature errors; the troubleshoot table reads that symptom.

LightMonitor: the Week 6 promise, kept

Week 6 closed by promising that "Week 9's MQTT client is an object whose callbacks you set", and that state plus behaviour would keep moving in together. Here is that sentence as code, the monitor refactored the way Week 6 refactored the reaction game: state (last_pct, a reading counter) in attributes, behaviour in methods, and, the new move, bound methods handed over as callbacks:

# lightmonitor.py: a monitor with memory
import paho.mqtt.client as mqtt

class LightMonitor:
    """Follows one station's light topic; remembers the latest reading."""

    def __init__(self, broker, station):
        self.topic    = f"bench/{station}/light"
        self.last_pct = None
        self.count    = 0
        self.client   = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
        self.client.on_connect = self.on_connect      # bound methods as callbacks:
        self.client.on_message = self.on_message      # self travels with the function
        self.client.connect(broker, 1883)

    def on_connect(self, client, userdata, flags, reason_code, properties):
        client.subscribe(self.topic)

    def on_message(self, client, userdata, msg):
        self.last_pct = float(msg.payload.decode())
        self.count += 1
        self.react()

    def react(self):
        print(f"[{self.count:4d}] {self.topic}: {self.last_pct:.1f} %")

    def run(self):
        self.client.loop_forever()

if __name__ == "__main__":
    LightMonitor("localhost", "s07").run()

self.on_message is a bound method: the object rides along inside it, so when paho calls it with a message, the method reaches self.last_pct without paho knowing objects are involved at all, the exact mechanism of Week 6's NightLight (button.when_pressed = self.press), now carrying network events. The design earns its keep immediately: every ladder rung below is a subclass-or-extend of this class, mostly by overriding react(), and Week 10's controller is this class with a control law where the print is.

The ladder

Rungs in order; each is demonstrable on its own. Rung 3 changes the system's shape, worth the picture first:

Bidirectional bench: telemetry flows from the node through the broker to the Pi on bench slash s07 slash light and status; commands flow from the Pi through the broker back to the node on bench slash s07 slash cmd. Two one-way topic streams through one broker make a two-way system. ESP32 node publishes + subscribes Broker routes both ways Pi monitor subscribes + publishes …/light …/status → ← …/cmd
Two one-way streams make a duplex system. Telemetry up, commands down, both just topics through the same broker; neither machine ever addresses the other. This picture is Week 10's control loop with the controller left blank.
  1. Status, properly. On the node, with STATUS = b"bench/s07/status": before connect(), lodge set_last_will(STATUS, b"offline", retain=True), and give MQTTClient an explicit keepalive=30; just after connecting, publish b"online", retained, to the same topic. Demonstrate the full theory: subscribe to status from a fresh terminal (the retained online arrives instantly), then pull the node's USB and time how long until offline appears, about a keep-alive and a half, exactly as the theory page priced it.
  2. The dark alert: first distributed behaviour. Wire Week 4's LED circuit on the Pi's breadboard (GPIO17, 330 Ω, the rules still apply) and override react(): below 20 %, self.led.on(), else off, gpiozero and paho in one class. Shade the sensor on one machine; a light answers on another. That is sense → network → decide → actuate, the full IoT arc, in about six lines of subclass.
  3. The command channel. Pi side: publish "led:on" to the station's cmd topic, self.client.publish(CMD, "led:on"), from a tiny keyboard loop (or on the dark threshold). Node side: set_callback(handle_cmd) then subscribe(CMD) on the client, and client.check_msg() added to the publish loop, the non-blocking "anything for me?" that fits a once-a-second rhythm; handle_cmd parses led:on/led:off/duty:30000 and drives the Week 8 PWM LED. The bench is now bidirectional, and remote actuation, the half of IoT that makes safety engineers careful, is yours, on a supervised LAN, with the security section's words ringing.
  4. Structured payloads. Publish {"pct": 42.3, "raw": 1876, "seq": 1041} instead: json.dumps(...).encode() on the node (MicroPython's module answers to json; older firmware says ujson), json.loads(msg.payload) in the monitor. One topic, several fields, self-describing: the format Week 11's dashboard will want, and the moment Week 8's "plain number now, structure later" promise matures.
  5. Stretch: memory or breadth. Either a LightLogger(LightMonitor) whose react() appends timestamp,pct to a CSV (Week 10's tuning sessions will thank you for the data), or a wildcard monitor on bench/+/light that keeps last_pct per station in a dictionary, your first taste of one consumer following a whole fleet.

Checklist for this stage

Check yourself

A subscriber assigns on_message, calls connect(), and ends. It never prints. Why?
No network loop ran: paho only reads the socket and fires callbacks inside loop_forever() (or a started background loop). Assigning callbacks is wiring; the loop is electricity, Week 4's pause lesson at network scale.
A monitor works all afternoon, survives a broker restart, and is silent ever after, though status shows it connected. Diagnose.
It subscribed once, outside on_connect. Paho auto-reconnected after the restart, but subscriptions die with the old connection, and nothing re-planted them. Subscribing inside on_connect makes every (re)connection re-subscribe, which is the whole argument for putting it there.
What exactly travels when you write self.client.on_message = self.on_message, and why does it fulfil Week 6's promise?
A bound method: a callable with the instance baked in. Paho later calls it like any function, and it lands with self attached, reaching the object's state (last_pct, count). It is button.when_pressed = self.press with the button replaced by a network, state and behaviour moved in together, as promised.
Rung 1's offline took about 45 seconds to appear after the USB pull, with keepalive=30. Account for the delay.
A power cut sends no disconnect packet; the broker discovers death by silence, at one and a half keep-alive intervals: 1.5 × 30 s = 45 s. Faster detection means a shorter keep-alive, priced in heartbeat traffic.
Why does the node use check_msg() in its loop rather than wait_msg()?
wait_msg() blocks until a message arrives, which would stop the sensor loop dead between commands. check_msg() is the non-blocking peek: handle a command if one is waiting, otherwise carry on reading and publishing, the single-loop version of doing two jobs.
Name the two topics and two payload directions that make rung 3 "bidirectional", and what never happens between the machines.
Telemetry up on bench/s07/light (node publishes, Pi subscribes); commands down on bench/s07/cmd (Pi publishes, node subscribes). What never happens: a direct connection, neither machine knows the other's address; both speak only to the broker.