Flask & the project: the system gets a face
420-302-VA · WEEK 11 · FALL 2026

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

One Python process on the Pi containing two threads. The MQTT network thread, started by loop underscore start, receives messages from the broker and writes the latest values into a shared dict named latest, holding pct, status and updated. The Flask thread answers browser requests by reading the same dict. The broker connects from the left, the browser from the right; the dict sits in the middle, written by one side, read by the other. app.py: one process, two threads MQTT thread loop_start() on_message writes Flask thread routes read latest = { "pct": 41.8, "status": "online", "updated": 14:32:05 } writes reads Broker localhost:1883 push Browser asks :5000 ask
Push on the left, ask on the right, a dict in the middle. The MQTT thread keeps 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:

A sequence diagram with three lifelines: node and broker on the left, the bridge dict in the middle, the browser on the right, time flowing downward. MQTT updates arrive at the dict roughly once per second, marked as pushes. Independently, the browser's fetch asks the Flask route every two seconds and each ask is answered immediately from whatever the dict holds. The two rhythms never align and never need to. node → broker latest (the dict) browser publish, ~1/s: the dict is kept true fetch /api/light, every 2 s answered at once, from the dict two independent rhythms; the dict is where they agree to meet
Nothing waits for anything. Pushes land when the node speaks; asks land when the timer fires; every ask is answered instantly from the latest truth. Worst-case staleness on screen is one publish interval plus one poll interval, about three seconds, honest and sufficient for a human dashboard.

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

A wireframe of the finished dashboard in a browser window. Title bar with the Pi's URL. Page heading Station s07 with a green online chip. A large live reading, 41.8 percent, with an updated-two-seconds-ago note. A target row with a number input showing 50 and a Set button. A small history sparkline at the bottom, a ladder item. http://192.168.1.42:5000/ Station s07 online 41.8 % light level · updated 2 s ago Target: 50 Set → POST /api/setpoint → cmd topic → the node's PID last minute (ladder rung 3) shade the sensor and watch the number dip, from any device on the network
The target for the hour. Live value, liveness chip, a control that moves a real LED, and (rung 3) a minute of history. Plain by design: the project's dashboard inherits this skeleton and spends its styling budget there.

The ladder

  1. 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 for online, gray for offline. Pull the node's USB and watch the chip turn within the keep-alive window, Week 9's LWT reaching a screen at last.
  2. 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.
  3. A minute of memory. In on_message, append (time, pct) to a collections.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.
  4. The fleet view. Subscribe bench/+/light, keep latest_by_station as 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.
  5. 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?
Because a route must answer the request in front of it, from state in hand: blocking it parks a server thread per viewer for up to a second, stacks up under load, and buys nothing, the dict already holds a value at most one publish old. The bridge exists precisely so no request ever needs to wait on the publisher's rhythm.
loop_forever versus loop_start: what changed and why here?
Same client, same callbacks; 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.
The debug reloader imports the module twice (parent and watched child), so the top-level MQTT setup runs twice: two clients, one shared id. Week 9's rule fires: the broker kicks the first when the second connects, they reconnect in turns, churn forever. Cure: 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?
The bridge layer: data reaches the broker but not the dict. Most likely either 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?
A frozen number looks identical to a live one. The timestamp lets the page compute the reading's age and show it, and rung 1's chip aside, an "updated 40 s ago" on a once-a-second feed is itself a diagnosis: the bridge or the node stopped. Dashboards must distinguish "steady" from "stale", and age is the honest way.
Design check: why does history live in its own deque and route rather than inside latest?
Different jobs, different shapes, different consumers: 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.