Build a Flask Web Dashboard on Pi Zero to Monitor and Control GPIO

A headless Pi Zero is convenient until you want to see what it is doing without opening an SSH session. A small web page solves this: any phone or laptop on the same Wi-Fi can open it, see live readings and press buttons that control hardware. Because the Zero W and Zero 2 W have Wi-Fi built in, they make neat, self-contained web-controlled devices.

In this tutorial you will build a dashboard with Flask, a lightweight Python web framework. The page shows CPU temperature, load, memory, disk usage and uptime, refreshes itself every few seconds through a JSON API, and has a button that toggles an LED on GPIO17. All of it is a single Python file, so it is easy to understand and extend.

How the Dashboard Is Structured

The program serves three URLs. The browser first loads the HTML page. JavaScript in the page then calls a JSON endpoint every five seconds and updates the numbers in place, so the page never fully reloads. Pressing the button sends a POST request to a third endpoint that changes the LED and returns the new state.

RouteMethodReturnsPurpose
/GETHTML pageThe dashboard itself
/api/statusGETJSONCurrent system readings and LED state
/api/ledPOSTJSONToggle the LED and report its new state

All the system data comes from files the Linux kernel exposes, so no extra libraries are needed. CPU temperature is in /sys/class/thermal/thermal_zone0/temp in thousandths of a degree, uptime in /proc/uptime, and memory in /proc/meminfo. Python's os.getloadavg() and shutil.disk_usage() give load and disk figures.

Hardware and Software Setup

Wire an LED with a 330 ohm resistor from GPIO17 (physical pin 11) to GND, exactly as in the gpiozero basics tutorial. Then install Flask from the Raspberry Pi OS repository, which avoids needing a virtual environment for this project.

Install Flask and gpiozero and find the Pi's IP address
sudo apt update
sudo apt install -y python3-flask python3-gpiozero

# Note the IP address so you can open the page from a phone
hostname -I

The Dashboard Program

Save this as dashboard.py and run it with python3 dashboard.py. Then open http://pizero.local:5000 or http://<ip-address>:5000 in a browser on the same network.

dashboard.py: Flask system monitor with LED control
#!/usr/bin/env python3
"""Minimal Flask dashboard for Raspberry Pi Zero."""
import os
import shutil

from flask import Flask, jsonify, render_template_string
from gpiozero import LED

app = Flask(__name__)
led = LED(17)  # BCM GPIO17, physical pin 11

PAGE = """<!doctype html>
<html>
<head>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Pi Zero Dashboard</title>
  <style>
    body { font-family: sans-serif; max-width: 420px; margin: 20px auto; padding: 0 12px; }
    td { padding: 6px 10px; border-bottom: 1px solid #ddd; }
    button { font-size: 1.1em; padding: 10px 20px; margin-top: 16px; }
  </style>
</head>
<body>
  <h2>Pi Zero Dashboard</h2>
  <table>
    <tr><td>CPU temperature</td><td id="temp">-</td></tr>
    <tr><td>Load (1 min)</td><td id="load">-</td></tr>
    <tr><td>Memory used</td><td id="mem">-</td></tr>
    <tr><td>Disk used</td><td id="disk">-</td></tr>
    <tr><td>Uptime</td><td id="uptime">-</td></tr>
    <tr><td>LED</td><td id="led">-</td></tr>
  </table>
  <button onclick="toggleLed()">Toggle LED</button>
<script>
  function show(d) {
    document.getElementById('temp').textContent = d.temp_c + ' C';
    document.getElementById('load').textContent = d.load1;
    document.getElementById('mem').textContent = d.mem_used_pct + ' %';
    document.getElementById('disk').textContent = d.disk_used_pct + ' %';
    document.getElementById('uptime').textContent = d.uptime;
    document.getElementById('led').textContent = d.led ? 'ON' : 'OFF';
  }
  async function refresh() {
    const r = await fetch('/api/status');
    show(await r.json());
  }
  async function toggleLed() {
    const r = await fetch('/api/led', { method: 'POST' });
    const d = await r.json();
    document.getElementById('led').textContent = d.led ? 'ON' : 'OFF';
  }
  refresh();
  setInterval(refresh, 5000);  // update every 5 seconds
</script>
</body>
</html>"""

def cpu_temp_c():
    with open('/sys/class/thermal/thermal_zone0/temp') as f:
        return round(int(f.read()) / 1000, 1)

def mem_used_pct():
    info = {}
    with open('/proc/meminfo') as f:
        for line in f:
            key, value = line.split(':')
            info[key] = int(value.split()[0])  # values are in kB
    used = info['MemTotal'] - info['MemAvailable']
    return round(100 * used / info['MemTotal'], 1)

def uptime_text():
    with open('/proc/uptime') as f:
        seconds = int(float(f.read().split()[0]))
    hours, rem = divmod(seconds, 3600)
    return f'{hours} h {rem // 60} min'

@app.route('/')
def index():
    return render_template_string(PAGE)

@app.route('/api/status')
def status():
    disk = shutil.disk_usage('/')
    return jsonify(
        temp_c=cpu_temp_c(),
        load1=round(os.getloadavg()[0], 2),
        mem_used_pct=mem_used_pct(),
        disk_used_pct=round(100 * disk.used / disk.total, 1),
        uptime=uptime_text(),
        led=led.is_lit,
    )

@app.route('/api/led', methods=['POST'])
def toggle_led():
    led.toggle()
    return jsonify(led=led.is_lit)

if __name__ == '__main__':
    # 0.0.0.0 listens on all interfaces so other devices can connect.
    # debug=False: the debug reloader would start a second process that
    # tries to claim GPIO17 again and fails.
    app.run(host='0.0.0.0', port=5000, debug=False)

How the Code Works

  • Single file: the HTML is stored in the PAGE string and served with render_template_string. For a bigger project, move it into a templates/ folder and use render_template.
  • MemAvailable, not MemFree: Linux uses spare memory for disk cache. MemAvailable estimates how much can actually be given to programs, so it gives a realistic used percentage on a 512 MB board.
  • JSON API: separating data from the page means the same /api/status endpoint can also be read by a script, a phone app or a home automation system.
  • POST for actions: changing hardware state uses POST rather than GET, so a browser prefetching links or a search crawler cannot toggle the LED by accident.
  • Polling interval: five seconds keeps CPU use negligible on a Zero W. Each request takes only a few file reads.

To start the dashboard at boot, create a systemd service as described in the systemd tutorial, with ExecStart=/usr/bin/python3 /home/student/dashboard.py and Restart=on-failure.

Where the Numbers Come From

ReadingSourceUnit in the source
CPU temperature/sys/class/thermal/thermal_zone0/tempMillidegrees Celsius
Load averageos.getloadavg(), read from /proc/loadavgAverage runnable tasks over 1, 5 and 15 minutes
Memory/proc/meminfo, MemTotal and MemAvailableKilobytes
Diskshutil.disk_usage('/')Bytes
Uptime/proc/uptime, first fieldSeconds since boot

Load average needs care when you read it. On a single-core Zero W, a load of 1.0 means the CPU is fully busy; on a four-core Zero 2 W, the same machine is only fully busy at about 4.0. If you see the load stay high while the dashboard is idle, some other process is using the CPU, and top will show which one.

Flask's development server handles each request in its own thread, so a phone and a laptop can both have the page open at once. Because every request only reads a few small kernel files, two or three viewers make no visible difference to the Zero. If you later add slow work, such as reading a sensor that takes a second, cache the last value in a background thread and let the API return the cached value instead of blocking the request.

Security Notes and Troubleshooting

  • Page does not load from the phone: the phone is on mobile data or a guest network. It must be on the same Wi-Fi as the Pi.
  • Address already in use: another copy of the dashboard is running, perhaps as a service. Stop it with sudo systemctl stop or pkill -f dashboard.py.
  • GPIO busy error at start: debug=True was enabled, or another script holds GPIO17.
  • Numbers show undefined: a key name in the JavaScript does not match the JSON. Open /api/status directly in the browser to inspect it.

Summary

With about a hundred lines of Python, the Pi Zero now serves a mobile-friendly dashboard that reads live system data from /proc and /sys and controls GPIO through a small JSON API. You can extend the same pattern with sensor readings from the BME280 logger, a relay instead of the LED, or a camera snapshot route, keeping the page and the API separate as the project grows.