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

Stage 3 of 6 · Lab · about 25 minutes

Flask: the Pi serves

Flask is Python's standard small web framework: a routing core, a template engine (Jinja), a development server, and deliberately little else. "Micro" is the design, not a limitation, and it is exactly the right size for a Pi serving a bench. Twelve lines from now, your Pi is a web server.

What Flask is

A web framework's job is the plumbing between the socket and your code: parse the request, match the path to a function, turn the function's return into a proper response. Flask, from the Pallets project, does that job and stops, which is why a complete app fits on a slide and why it has carried a generation of dashboards, APIs and course projects. Install it where every Pi-side library has lived since Week 4, the project venv:

(.venv) you@lastname-pi:~/iot-labs $ pip install flask

First serve

# app.py: the smallest real web server
from flask import Flask

app = Flask(__name__)

@app.route("/")
def home():
    return "<h1>Station s07</h1><p>The Pi is serving.</p>"

app.run(host="0.0.0.0", port=5000)
(.venv) you@lastname-pi:~/iot-labs $ python app.py
 * Serving Flask app 'app'
 * Running on all addresses (0.0.0.0)
 * Running on http://127.0.0.1:5000
WARNING: This is a development server. Do not use it in a production deployment.

Read the pieces with the week's theory in hand. Flask(__name__) constructs the application object, Week 6 as ever. The decorator @app.route("/") registers home as the answerer for the root path. The return value is the response body, and Flask fills in the 200 and the headers. The warning banner is honesty, not failure: this built-in server is made for development and benches, and production fronts Flask with a hardened server and TLS, the same scoped-decision language as Week 9's broker. The bench runs the dev server, knows it, and writes it down.

Reachable: a wall you have met before

Two things stand between your server and the rest of the bench network, and you have torn down both before. First, the bind address: host="0.0.0.0" means "listen on all interfaces". Leave it out and Flask binds to loopback only, 127.0.0.1, reachable from the Pi alone, which is exactly Mosquitto's Week 9 wall wearing a Python costume; same concept, same symptom (works in a Pi terminal, refuses everyone else), same fix (bind wider, on purpose). Second, the firewall, and by now the ritual writes itself:

you@lastname-pi:~ $ sudo ufw allow 5000
you@lastname-pi:~ $ sudo ufw status verbose
22/tcp                     ALLOW IN    Anywhere
1883                       ALLOW IN    Anywhere
5000                       ALLOW IN    Anywhere

Now the moment: take out your phone, join the bench Wi-Fi, and browse to the Pi's address, port 5000 (the URL anatomy, live). A page you wrote, served by a computer you configured, to a device nobody wired. Keep the phone handy; it is the week's best test instrument, because it shares none of your laptop's state.

Routes: the URL contract

Flask's routing. Three incoming requests, GET slash, GET slash api slash light, and POST slash api slash setpoint, enter a route-table box that maps each path to a Python function: home, api_light, api_setpoint. Each function's return leaves as a response: an HTML page, a JSON object, a JSON confirmation. A fourth request, GET slash nope, matches nothing and leaves as a 404. GET / GET /api/light POST /api/setpoint GET /nope the route table "/" → home() "/api/light" → api_light() "/api/setpoint" → api_setpoint() no match → 404 200 · the dashboard page 200 · JSON: the latest values 200 · JSON: ok, new setpoint 404 · not found
A path is a promise; a function keeps it. The decorator writes one row of this table, matching is exact (method and path both), and anything unmatched is a clean 404. These three rows are the dashboard's entire public surface, which is why designing the paths deserves the same care as Week 9's topics.

Add routes by adding decorated functions; restrict methods where the verb's contract demands it (methods=["POST"] on anything that acts). One craft note carried over from topic design: keep paths lowercase, hierarchical and stable, with data routes gathered under /api/, so a page, a script and next term's curiosity all find the same contract in the same place.

Templates: HTML with blanks

Pages as Python strings stop scaling at about the second tag. Flask's answer is Jinja templates: real HTML files with {{ placeholders }}, rendered per request. Two rules do most of the work: templates live in a folder named exactly templates/ beside app.py, and render_template fills the blanks from keyword arguments:

# in app.py
from flask import render_template

@app.route("/")
def home():
    return render_template("dashboard.html", station="s07")
<!-- templates/dashboard.html -->
<html>
  <head><title>Station {{ station }}</title></head>
  <body>
    <h1>Station {{ station }}</h1>
    <p>Light: <span id="pct">–</span> %</p>
  </body>
</html>

The blanks fill at serve time on the server; the live numbers will arrive later, in the browser, by JavaScript, and the next page explains that division of labour. Jinja goes much further (loops, conditionals, inheritance) and your project may want it; the reference is in Resources.

The first API route

Pages are for people; dashboards also need routes that answer with data. Flask makes JSON a one-liner, and the format is an old friend, Week 9's structured-payload rung, now crossing the other protocol:

# a data route: answers with JSON, for any client
from flask import jsonify

@app.route("/api/light")
def api_light():
    return jsonify(pct=42.3, status="online")   # demo values, for now

Browse to it and the browser shows the raw JSON; curl from a terminal shows the same; so will the dashboard's JavaScript. The hard-coded 42.3 is this page's only lie, and the next page replaces it with the truth flowing in from the broker.

Checklist for this stage

Check yourself

Your laptop gets "connection refused" from the Pi's port 5000, while the page loads fine in a browser on the Pi itself. Which of the two walls is this, and why is the symptom decisive?
The bind wall: refusal from off-machine plus success on-machine is the loopback signature, Flask is listening on 127.0.0.1 only, so the Pi answers itself and actively refuses everyone else. Add host="0.0.0.0". A firewall block would drop silently and time out instead, Week 9's timeout-versus-refused reading, reused verbatim.
Why is the dev-server warning acceptable on the bench, in the same sentence structure you used for allow_anonymous in Week 9?
It is a scoped, documented decision: a development server, on a supervised local network, serving a light level, for a lesson whose focus is the application. Production would be negligence with the same setup, and there a hardened WSGI server and TLS replace it. Know what you opened, write it down, never copy a bench config to the world.
What exactly does the @app.route decorator do, in route-table language?
It writes one row: this path (and method set) maps to this function. At request time Flask matches the incoming method and path against the table, calls the matched function, and wraps its return as the response; no match, 404. The decorator is registration, not execution; the function runs once per matching request.
render_template raises TemplateNotFound even though dashboard.html sits right next to app.py. Diagnose.
Flask looks only inside a folder named exactly templates/ beside the app, not beside it in the same directory. Make the folder, move the file in, done. It is a convention-over-configuration rule: one fixed place, zero setup.
Why do the live numbers arrive by JavaScript in the browser rather than by {{ pct }} in the template?
A template fills once, at serve time: {{ pct }} would freeze the value as of the page load, and seeing a new reading would mean reloading the whole page. The division of labour is: template for the page's skeleton and identity, JavaScript asking /api/light for the changing numbers, which is the next page's whole subject.