Stage 4 of 6 · Lab · about 35 minutes, plus the ladder
The dashboard
Flask answers when asked; the broker speaks when it likes. Joining those two rhythms is the week's real engineering, and the join is humbler than it sounds: a dictionary. This page builds the bridge, puts a live page in every browser on the bench, and closes the circle by steering Week 10's control loop from a phone.
Two rhythms, one server
Name the mismatch precisely, because your project will meet it again in other costumes. MQTT delivers on the publisher's schedule: the light topic fires about once a second, whenever the node speaks. HTTP answers on the client's schedule: a request can land at any instant and must be answered from what the server holds right then, statelessness, as the theory page promised. A route cannot "wait for the next message"; a callback cannot "answer a request that has not arrived". The standard resolution is shared state in the middle: one side keeps it current, the other reads it on demand, and neither waits for the other.
The bridge: shared state
latest true; routes read it whenever a request lands; neither rhythm ever waits for the other. It is a cache of now, three keys and no history, and that smallness is the design.Two supporting facts. First, loop_start(): Week 9's loop_forever() occupied the whole program, which was fine when listening was the whole program; here Flask must own the foreground, so paho offers the threaded sibling, same callbacks, background thread, returns immediately. Second, a thread-safety note at bench honesty level: the writer replaces whole values in a dict while readers copy them out, which Python handles safely at this scale; the moment a project wants multi-step updates or histories shared across threads, reach for a lock or a queue, names worth knowing and not needing today.
The bridge in code
# app.py: the bridge. MQTT keeps `latest` true; Flask serves it. import time import paho.mqtt.client as mqtt from flask import Flask, render_template, jsonify, request STATION = "s07" # yours latest = {"pct": None, "status": "unknown", "updated": None} def on_connect(client, userdata, flags, reason_code, properties): client.subscribe([(f"bench/{STATION}/light", 0), (f"bench/{STATION}/status", 0)]) def on_message(client, userdata, msg): if msg.topic.endswith("/light"): latest["pct"] = float(msg.payload.decode()) latest["updated"] = time.time() else: latest["status"] = msg.payload.decode() mqttc = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) mqttc.on_connect = on_connect mqttc.on_message = on_message mqttc.connect("localhost", 1883) mqttc.loop_start() # background thread; Flask gets the foreground app = Flask(__name__) @app.route("/") def home(): return render_template("dashboard.html", station=STATION) @app.route("/api/light") def api_light(): return jsonify(latest) @app.route("/api/setpoint", methods=["POST"]) def api_setpoint(): sp = float(request.form["sp"]) mqttc.publish(f"bench/{STATION}/cmd", f"sp:{sp:.0f}") return jsonify(ok=True, sp=sp) app.run(host="0.0.0.0", port=5000)
Every line is a week you have already lived: the subscriptions and decode are Week 9's contract, the status topic is the retained-LWT pattern, the cmd publish is Week 10's supervisory channel, and the whole file is a composition of objects doing their jobs. One sharp edge, promoted here because everyone finds it: if you turn on debug=True, Flask's auto-reloader imports the module twice, so two MQTT clients with the same id connect and kick each other forever; the troubleshoot table has the symptom, and use_reloader=False is the cure when you want debug.
The page: fetch and refresh
The template's skeleton came last page; now its JavaScript asks /api/light on a timer and writes the answers into the page:
<!-- inside templates/dashboard.html, before </body> --> <p>Light: <span id="pct">–</span> %</p> <p>Target: <input id="sp" type="number" value="50" min="0" max="100"> <button onclick="sendSP()">Set</button></p> <script> async function refresh() { const r = await fetch("/api/light"); // GET, every 2 s const d = await r.json(); document.getElementById("pct").textContent = d.pct === null ? "–" : d.pct.toFixed(1); } async function sendSP() { const sp = document.getElementById("sp").value; await fetch("/api/setpoint", { // POST: this one acts method: "POST", body: new URLSearchParams({sp}) }); } setInterval(refresh, 2000); refresh(); </script>
JavaScript is this course's guest, not its subject, so read it with Python eyes and the patterns resolve themselves: fetch performs the HTTP exchange from the theory page; async/await is "this takes network time, continue when it lands"; the getElementById(...).textContent line is the browser's version of printing into a label; and setInterval(refresh, 2000) is a LOOP FOREVER with a 2-second WAIT, Week 5's conventions compiling to a browser. The timing of the whole system is worth one picture:
Polling, defended
Yes, the browser polls, and yes, Week 9 argued against polling; say the resolution out loud once so it sticks. Week 9's indictment was consumers polling constrained devices, many-to-many, battery and bandwidth spent on "nothing changed". This is one browser asking one mains-powered aggregator over a local link, at a pace chosen for human eyes, while the devices themselves still push; the pattern fits the link. When a project genuinely needs push-to-browser (sub-second updates, many viewers), the web's answers are WebSockets and server-sent events, names to keep; at this course's timescale, polite polling is the right-sized tool.
What done looks like
The ladder
- The status chip. The bridge already carries
latest["status"]from the retained topic; surface it: a span whose text and CSS class follow the value, green foronline, gray foroffline. Pull the node's USB and watch the chip turn within the keep-alive window, Week 9's LWT reaching a screen at last. - The full circle. Wire the Set button end to end and say the path aloud as you demo it: browser → POST → Flask → publish on
cmd→ node's handler → the local PID's new setpoint → the LED moves → the light topic reports it → the dict updates → the number on your phone follows. Seven weeks of the course in one button press, and the supervisory pattern from Week 10 now has a human on top. - A minute of memory. In
on_message, append(time, pct)to acollections.deque(maxlen=60); serve it at/api/history; draw it in the page (a polyline on an SVG or canvas, 15 lines of JS, or a plain table first). The bridge stays a cache of now; history is its own small structure with its own route. - The fleet view. Subscribe
bench/+/light, keeplatest_by_stationas a dict of dicts, serve/api/stations, render a table of every station on the bench. One browser watching the whole room is the SCADA shape from Week 10's cards, at bench scale. - Stretch: polish or instrument. Either a styling pass (a
<style>block, readable at arm's length, your project's face deserves it) or wire Week 10's step-test instrument in: a button that triggers the test and a route that serves its computed verdicts.
Checklist for this stage
Check yourself
Why can't api_light simply wait for the next MQTT message and return that?
loop_forever versus loop_start: what changed and why here?
loop_forever() runs the network loop in the calling thread and never returns, which was fine when the monitor was the whole program. Flask must own the foreground to serve, so loop_start() runs the identical loop on a background thread and returns immediately: two rhythms, two threads, one process.With debug=True the node's connection starts dropping every few seconds and the terminal shows two startup banners. Explain the whole chain.
app.run(..., debug=True, use_reloader=False), or leave debug off on the bench.The dashboard shows pct: null though mosquitto_sub shows the node publishing. Which layer, and the two most likely causes?
loop_start() was never called (callbacks need a running loop, Week 9's rule), or the subscription topic does not exactly match the published one (the STATION constant, case, s7 vs s07). The firehose plus a print inside on_message splits the two in one minute.Why does the bridge store updated as a timestamp instead of the page just trusting the latest pct?
Design check: why does history live in its own deque and route rather than inside latest?
latest is a tiny cache of now that every poll reads; history is a bounded series that only the sparkline wants, heavier to serialize and pointless to resend every 2 s. Separating them keeps each route's answer exactly as big as its question, the same one-value-per-topic instinct from Week 9's namespace design.