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?
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.
.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.
Two stations both name their client "node". What happens at the broker?
node-s07, exists to make collisions impossible.