10 · Capstone — WiFi Sensor Logger¶
Time to combine every module into one real device: a WiFi sensor logger
that reads a DHT22, keeps a bounded log on flash, shows live status on an
OLED, and serves a tiny web dashboard — a JSON endpoint plus an HTML page —
from an async web server. It's structured the way real MicroPython projects
are: several small files with clear jobs, a JSON config, and uasyncio
tasks tying it together. The whole thing runs in a Wokwi MicroPython on
ESP32 project with zero hardware.
What you're building¶
- Sense — DHT22 temperature/humidity every N seconds (module 6)
- Log — readings into a RAM ring buffer, flushed to a CSV on flash with rotation (module 7)
- Display — OLED shows IP, latest reading, and reading count (module 6)
- Serve —
/returns an HTML dashboard,/api/datareturns JSON (modules 8–9) - Blink — heartbeat LED so you can see the event loop is alive (module 3)
Wiring¶
Create a Wokwi MicroPython on ESP32 project and add parts with the diagram + button:
| Part | Part pin | ESP32 pin |
|---|---|---|
| DHT22 | VCC / GND / SDA | 3V3 / GND / 15 |
| SSD1306 OLED | VCC / GND / SCL / SDA | 3V3 / GND / 22 / 21 |
| LED (+ 220 Ω to GND) | anode via resistor | 2 |
Add the ssd1306.py driver as a project file (module 6 explains where to
get it), then create the four files below with the editor's new-file button.
config.json¶
{
"wifi_ssid": "Wokwi-GUEST",
"wifi_password": "",
"read_interval_s": 5,
"log_file": "log.csv",
"log_max_bytes": 4096,
"ring_size": 30
}
sensors.py — reading + ring buffer + flash log¶
# sensors.py — DHT22 sampling, in-RAM ring buffer, CSV flush with rotation
import dht
import os
import time
from machine import Pin
class SensorLog:
def __init__(self, cfg):
self.sensor = dht.DHT22(Pin(15))
self.ring = [None] * cfg["ring_size"] # pre-allocated ring buffer
self.head = 0 # next write slot
self.count = 0 # total successful readings
self.errors = 0
self.latest = None # (uptime_s, temp, hum)
self.log_file = cfg["log_file"]
self.log_max = cfg["log_max_bytes"]
def read(self):
"""Take one reading. Returns True on success."""
try:
self.sensor.measure()
except OSError:
self.errors += 1
return False
entry = (time.ticks_ms() // 1000,
self.sensor.temperature(),
self.sensor.humidity())
self.ring[self.head] = entry
self.head = (self.head + 1) % len(self.ring)
self.count += 1
self.latest = entry
self._append_csv(entry)
return True
def recent(self):
"""Ring contents, oldest first."""
n = len(self.ring)
items = [self.ring[(self.head + i) % n] for i in range(n)]
return [e for e in items if e is not None]
def _append_csv(self, entry):
try:
self._rotate_if_needed()
with open(self.log_file, "a") as f:
f.write("{},{:.1f},{:.1f}\n".format(*entry))
except OSError:
pass # full/failing flash must not kill sampling
def _rotate_if_needed(self):
try:
if os.stat(self.log_file)[6] > self.log_max:
old = self.log_file + ".old"
try:
os.remove(old)
except OSError:
pass
os.rename(self.log_file, old)
except OSError:
pass # log doesn't exist yet
Design notes: the ring is pre-allocated (no growth, predictable
memory), a failed read only bumps errors, and a failing flash never stops
sampling — priorities a desktop program rarely needs.
webapp.py — async web server¶
# webapp.py — async HTTP: HTML dashboard + JSON API
import json
import uasyncio as asyncio
PAGE = """<!DOCTYPE html>
<html><head><title>ESP32 Sensor Logger</title>
<meta http-equiv="refresh" content="5"></head>
<body style="font-family:sans-serif">
<h1>ESP32 Sensor Logger</h1>
<p>Uptime {up} s · readings {count} · errors {errors}</p>
<h2>{temp} °C {hum} %</h2>
<p><a href="/api/data">JSON API</a></p>
</body></html>
"""
class WebApp:
def __init__(self, log):
self.log = log
async def handle(self, reader, writer):
try:
request = await reader.readline() # e.g. b"GET /api/data HTTP/1.1"
while await reader.readline() != b"\r\n": # drain headers
pass
parts = request.split()
path = parts[1].decode() if len(parts) > 1 else "/"
if path == "/api/data":
body = json.dumps({
"latest": self.log.latest,
"recent": self.log.recent(),
"count": self.log.count,
"errors": self.log.errors,
})
ctype = "application/json"
else:
t = self.log.latest
body = PAGE.format(
up=t[0] if t else 0,
count=self.log.count, errors=self.log.errors,
temp="%.1f" % t[1] if t else "--",
hum="%.1f" % t[2] if t else "--")
ctype = "text/html"
writer.write("HTTP/1.0 200 OK\r\nContent-Type: {}\r\n\r\n"
.format(ctype).encode())
writer.write(body.encode())
await writer.drain()
except OSError:
pass # dropped client — server lives on
finally:
writer.close()
await writer.wait_closed()
asyncio.start_server (called from main.py) hands each client to
handle() as its own task — the server never blocks the samplers, which
is exactly what module 8's blocking version couldn't do.
main.py — wiring it all together¶
# main.py — WiFi sensor logger capstone
import json
import network
import time
import uasyncio as asyncio
from machine import Pin, I2C
import ssd1306
from sensors import SensorLog
from webapp import WebApp
# --- config (module 7 pattern) ---
DEFAULTS = {"wifi_ssid": "Wokwi-GUEST", "wifi_password": "",
"read_interval_s": 5, "log_file": "log.csv",
"log_max_bytes": 4096, "ring_size": 30}
try:
with open("config.json") as f:
cfg = dict(DEFAULTS, **json.load(f))
except (OSError, ValueError):
cfg = DEFAULTS
# --- hardware ---
led = Pin(2, Pin.OUT)
i2c = I2C(0, scl=Pin(22), sda=Pin(21))
oled = ssd1306.SSD1306_I2C(128, 64, i2c)
log = SensorLog(cfg)
def wifi_connect():
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.connect(cfg["wifi_ssid"], cfg["wifi_password"])
for _ in range(100):
if wlan.isconnected():
return wlan.ifconfig()[0]
time.sleep_ms(200)
return None
# --- tasks ---
async def heartbeat():
while True:
led.value(not led.value())
await asyncio.sleep_ms(500)
async def sampler():
while True:
log.read()
await asyncio.sleep(cfg["read_interval_s"])
async def display(ip):
while True:
oled.fill(0)
oled.text("IP:" + (ip or "offline"), 0, 0)
if log.latest:
oled.text("T: {:.1f} C".format(log.latest[1]), 0, 20)
oled.text("H: {:.1f} %".format(log.latest[2]), 0, 32)
oled.text("n={} e={}".format(log.count, log.errors), 0, 52)
oled.show()
await asyncio.sleep(1)
async def main():
ip = wifi_connect()
print("IP:", ip)
asyncio.create_task(heartbeat())
asyncio.create_task(sampler())
asyncio.create_task(display(ip))
if ip:
app = WebApp(log)
await asyncio.start_server(app.handle, "0.0.0.0", 80)
print("Dashboard: http://%s/" % ip)
while True:
await asyncio.sleep(3600)
asyncio.run(main())
Boot order: load config (defaults if broken), connect WiFi with a bounded wait (an offline device still samples and displays), then launch the tasks. Every failure mode has a decision, not an accident.
Test plan¶
Verify each claim before calling it done:
- Boot — serial shows the IP; OLED shows IP +
n=0 e=0; LED blinks at 1 Hz continuously (if it ever stops, a task is blocking the loop). - Sensing — drag the Wokwi DHT22 sliders; OLED updates within
read_interval_s;n=climbs. - Failure — in
sampler(), temporarily point the DHT22 at an empty pin (or raiseOSErroryourself):e=climbs, everything else keeps running. Restore it. - Logging —
Ctrl-Cto the REPL;open("log.csv").read()shows one CSV line per reading; let it run past 4 KB and confirmlog.csv.oldappears (import os; os.listdir()). - Web — via the Wokwi IoT gateway (or the board's IP on real
hardware):
/shows the dashboard and auto-refreshes;/api/datareturns valid JSON with at mostring_sizeentries; hammering refresh never stops the LED heartbeat. - Config — set
read_interval_sto 2 inconfig.json, reset, confirm faster sampling; corrupt the JSON, reset, confirm it boots on defaults.
How It Actually Works¶
This capstone is a good place to see how the individually-explained mechanisms of modules 1–9 actually compose under real concurrent load on a single core with a fixed heap.
- All four tasks (heartbeat, sampler, display, and every web client
handler) share one event loop and one heap, so a memory or timing mistake
in any one of them is visible everywhere.
SensorLog's ring buffer is pre-allocated for exactly this reason: ifsensors.pyinstead appended to a growing list on every reading, the allocator would eventually have to find space during apoll_sensor-style task's turn, potentially triggering a GC pass (module 3 territory) that pauses every task, including the heartbeat LED — so "n=0 e=0 with a steady 1 Hz blink" in the test plan is really a proxy for "no task is doing unbounded allocation." asyncio.start_serverhands each incoming connection its own task, so the web server's blocking-lookingawait reader.readline()doesn't block the sampler. Eachhandle()invocation is a fresh coroutine the loop schedules independently; when it awaits a socket read, the loop parks that one task on the socket's readiness and moves on to whatever else is ready — the sampler'sawait asyncio.sleep(cfg["read_interval_s"])and the display's redraw keep firing on schedule regardless of how many browsers are hitting/api/data. This is the mechanical reason this design succeeds where module 8's single-accept()-loop server couldn't.- The bounded WiFi-connect loop (
for _ in range(100): ... time.sleep_ms(200)) is deliberately not async — it runs beforeasyncio.run(main())starts the event loop, so there's no loop yet to yield to; blocking here is the only option, which is exactly why it's bounded (100 × 200 ms = 20 s max) rather than an unboundedwhile not wlan.isconnected(). An offline device that still samples and displays is a direct consequence of not letting that pre-loop wait become infinite. - Every
except OSError: passinsensors.pyandwebapp.pyis absorbing a different physical failure with the same Python exception type — a flash write hittingENOSPC, a DHT22 timing out on its one-wire protocol, a TCP client resetting its connection mid-response — because MicroPython's C layer maps nearly all low-level hardware and networking failures ontoOSErrorwith an errno, rather than a rich exception hierarchy. Catching the general case is the practical, if coarse, way an embedded program stays alive through the failure modes its desktop counterpart rarely has to consider at all.
Cheat sheet — the patterns this project locked in¶
| Pattern | Where |
|---|---|
| Config file with defaults + corrupt-file fallback | main.py top |
| Pre-allocated ring buffer, oldest-first read-out | sensors.py |
| CSV append + size-based rotation | sensors.py |
| Sensor errors counted, never fatal | SensorLog.read() |
| Bounded WiFi wait; offline mode still useful | wifi_connect() |
One async task per concern; shared state via one object |
main.py |
| Async server: per-client handler task, always-close | webapp.py |
| Heartbeat LED as a liveness probe | heartbeat() |
Exercises¶
- Alerts — add
temp_alert_cto the config; when exceeded, blink the heartbeat LED at 5 Hz and showALERTon the OLED until it drops back. - Button stats page — add a button (pin 4) that cycles the OLED
between the live view and a min/max/average view computed from
recent(). - Chart — make
/render the ring buffer as an inline SVG polyline of temperature — no JavaScript needed, just string-building server-side. - Push, don't just serve — every 60 s, POST the latest reading as
JSON to
https://httpbin.org/post(module 8) and count successes and failures on the OLED. - Port it — move the project to a Pico W in Wokwi: change the I2C pins and SSID, and note everything that didn't need changing.
That's Level 1 complete. You can now build a networked, multi-tasking, crash-tolerant Python device from scratch. Level 2 takes the same hardware into MQTT, BLE, deep sleep, and real tooling.