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

Stage 4 of 6 · Lab · about 25 minutes

The node goes Wi-Fi

The ESP32 has carried a radio all along; MicroPython's network module switches it on. This page joins the bench network, handles credentials like a professional, installs the MQTT client, and puts last week's calibrated light percentage on the air, where the broker you just built is waiting for it.

Joining Wi-Fi from MicroPython

The network module manages the radio through a WLAN object in station mode (STA_IF: the board joins an existing network, as opposed to AP_IF, where it would broadcast one). Joining is asynchronous, connect() returns immediately while the radio negotiates, so real code waits with a timeout instead of hoping. Put this in a file net.py on the board:

# net.py: join Wi-Fi, or say clearly why not
import network
from time import sleep

def connect_wifi(ssid, password, timeout_s=10):
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    if not wlan.isconnected():
        print("Joining", ssid, "...")
        wlan.connect(ssid, password)
        for _ in range(timeout_s * 2):        # poll every half second
            if wlan.isconnected():
                break
            sleep(0.5)
    if not wlan.isconnected():
        raise RuntimeError("Wi-Fi failed: check SSID, password, and that the network is 2.4 GHz")
    print("Wi-Fi OK, address:", wlan.ifconfig()[0])
    return wlan

Read it with Week 6 eyes: WLAN(...) constructs an object; active, connect, isconnected, ifconfig are its methods; the loop is Week 5's bounded-wait pattern rather than a LOOP FOREVER that hangs a classroom. ifconfig()[0] is the board's own IP address on the network, proof of membership, and the error message names the classic failure: the ESP32's radio is 2.4 GHz only, so a 5 GHz-only network simply does not exist for it.

config.py: secrets out of Git

The function needs an SSID and a password, and the publish loop will need the broker's address. None of that belongs in code, passwords because your repository is on GitHub and Week 3 taught you what the internet does with leaked credentials; addresses because they change between bench and home, and Week 8's calibration constants already showed you where per-station facts live. The pattern, industry-standard in miniature, is a configuration file that Git never sees:

# config.py: THIS STATION's facts. Git-ignored; never committed.
WIFI_SSID     = "bench-net"          # given in class
WIFI_PASSWORD = "..."                # given in class
BROKER_IP     = "192.168.1.42"       # the Pi: hostname -I on the Pi says
STATION       = "s07"                # your station number
you@lastname-pi:~/iot-labs $ echo "config.py" >> .gitignore

Then commit a config_example.py, same keys, placeholder values, so the repository documents what configuration is needed without containing anyone's. This two-file move (.gitignore the real one, commit the example) is the standard professional answer to "where do secrets live", and markers will look for it.

The MQTT client: umqtt.simple

MicroPython's MQTT client is umqtt.simple, from the official micropython-lib collection: one small class, QoS 0 and 1, exactly the subset a sensor node needs. Some firmware builds ship it frozen in; test with a one-line from umqtt.simple import MQTTClient in the REPL. On an ImportError, install it from the board, over the Wi-Fi you just brought up, with mip, MicroPython's package installer:

# In the REPL, after connect_wifi(...) has succeeded, once per board:
>>> import mip
>>> mip.install("umqtt.simple")
Installing umqtt.simple ... Done

(Thonny's Tools → Manage packages can do the same install onto the device.) One API trait to respect, straight from the library's design notes: topics and payloads are bytes, because bytes are what the network socket speaks and the node has no memory to waste on conversions. Hence the b"..." literals and .encode() calls below, and the matching .decode() on the Pi.

The publish loop

Everything converges: the Week 8 LightNode class reads and calibrates; the broker routes; the topic scheme names. The node's whole job is eight lines of loop:

# main logic: publish the calibrated light level once a second
from umqtt.simple import MQTTClient
from time import sleep
import config
from lightnode import LightNode          # your Week 8 class, unchanged

TOPIC = f"bench/{config.STATION}/light".encode()

node   = LightNode(adc_pin=34, pwm_pin=25, dark=200, bright=3550)
client = MQTTClient(f"node-{config.STATION}", config.BROKER_IP)
client.connect()
print("MQTT connected to", config.BROKER_IP)

while True:
    pct = node.read_pct()
    client.publish(TOPIC, f"{pct:.1f}".encode())
    sleep(1)

Notes worth their lines: the client id node-s07 must be unique per broker, so the station number does double duty; the payload is Week 8's telemetry format decision, now load-bearing; dark/bright are your calibration constants, not these; and the sensor sits on GPIO 34, ADC1, which is the Week 8 "told you so": ADC2 pins stop working the moment Wi-Fi is active, and a node wired to ADC2 last week would be mysteriously dead right now. Yours is not, because the rule arrived before the radio did.

The moment

On the Pi, in a spare SSH terminal, subscribe to your station's branch and run the node:

you@lastname-pi:~ $ mosquitto_sub -t "bench/s07/#" -v
bench/s07/light 41.8
bench/s07/light 42.3
bench/s07/light 12.6     ← your hand, over the sensor
bench/s07/light 41.9

Shade the LDR and watch a terminal on one computer report, within a second, what your hand does above another. No cable connects them. That line of output is the course's thesis stated in four columns, and it is permanent: from here to the final presentation, your bench is a networked system.

Making it an appliance

Week 8's main.py rule holds: save the loop (with its imports and a top-level connect_wifi(config.WIFI_SSID, config.WIFI_PASSWORD) call) as main.py on the board and the node becomes an appliance, powering up into its job from any USB socket, no laptop, no Thonny. Keep the stop ritual too: Ctrl-C in the REPL reclaims the board when you want it back. One habit upgrade for networked main.py files: wrap the connect calls so a failure prints and retries after a pause instead of dying silently, the troubleshoot page's first rung exists because unwrapped versions of this file are its best customer.

Checklist for this stage

Check yourself

Why does connect_wifi poll isconnected() in a bounded loop instead of trusting connect()?
connect() only starts the negotiation and returns at once; membership arrives (or does not) seconds later. The bounded loop waits just long enough and then fails loudly with a useful message, Week 5's bounded-wait discipline against both lying success and infinite hangs.
A classmate hard-codes the Wi-Fi password in main.py "just for today" and pushes. What is wrong, and what is the correct two-file pattern?
The password is now in the repository's history on GitHub, effectively published, and removing it later does not unpublish it (Week 1: commits are permanent records). Correct: real values in config.py, listed in .gitignore; a committed config_example.py with placeholders documents the needed keys.
Why must the install order be Wi-Fi first, then mip.install?
mip runs on the board and downloads the package over the board's own network connection; before Wi-Fi is up the board has no route to anywhere. (Thonny's package manager is the alternative route through the USB cable.)
The payload is f"{pct:.1f}".encode(). Justify both the formatting and the encode.
The format is Week 8's telemetry decision: one decimal is the honest precision of a calibrated LDR, and a fixed format makes every consumer's parsing trivial. The .encode() turns the string into bytes because umqtt sends bytes, what sockets carry; the Pi mirrors it with .decode().
A node wired exactly like yours, but with the LDR on GPIO 25's neighbour GPIO 26, worked perfectly last week and reads garbage today. Diagnose.
GPIO 26 is an ADC2 pin, and ADC2 is unavailable while Wi-Fi is active; the radio came up today. This is the Week 8 rule ("ADC1, pins 32-39, because Week 9 is coming") arriving on schedule. Move the sensor to an ADC1 pin.
Two stations both name their client "node". What happens at the broker?
Client ids must be unique per broker: when the second connects, the broker drops the first's connection (and a reconnect-loop main.py turns that into connect-kick-reconnect churn between the two). The station-numbered id, node-s07, exists to make collisions impossible.