pico intermediate 30 min

Pico W: serve a live sensor dashboard over Wi-Fi

Read a DHT22 or BME280 and serve a live dashboard from a Pico W. The smallest, cheapest way to put a sensor reading on your phone's browser.

Code available for: MicroPython
Published Aug 25, 2026

The Pico W plus a DHT22 is the smallest useful sensor dashboard you can build. The chip is $6, the sensor is $2, and you get a live web page that shows the temperature and humidity on any phone or laptop on the same Wi-Fi. No cloud, no app, no MQTT broker.

The pattern is the same as the ESP32 web server tutorial: read the sensor, build a small HTML page with the values, serve it on port 80. The Pico W runs MicroPython, so the code is shorter than the ESP32 version and easier to read.

What you need

  • Raspberry Pi Pico W (the W version is required for Wi-Fi)
  • DHT22 or BME280 sensor
  • 4.7 kohm pull-up resistor (for DHT22, between the data pin and 3.3V)
  • MicroPython firmware installed (v1.20+ has Wi-Fi)
  • A known Wi-Fi network

Wiring

DHT22

DHT22 VCC -- Pico 3.3V (pin 36)
DHT22 GND -- Pico GND  (pin 38)
DHT22 DAT -- Pico GPIO 4 (pin 6)

Add a 4.7 kohm pull-up between DAT and 3.3V. The DHT22’s open-drain output needs it.

BME280 (over I2C)

BME280 VCC -- Pico 3.3V (pin 36)
BME280 GND -- Pico GND  (pin 38)
BME280 SDA -- Pico GPIO 0 (pin 1, default I2C0 SDA)
BME280 SCL -- Pico GPIO 1 (pin 2, default I2C0 SCL)

The Pico’s default I2C pins are GPIO 0 and 1. If you have other devices on the I2C bus, the BME280 is address 0x76 (most boards) or 0x77 (if SDO is tied to VCC).

The code (DHT22 version)

Save this as main.py on the Pico W:

import network
import socket
import time
from machine import Pin
import dht

SSID = "your-wifi-ssid"
PASSWORD = "your-wifi-password"
DHT_PIN = 4

sensor = dht.DHT22(Pin(DHT_PIN))
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.connect(SSID, PASSWORD)

print("Connecting to Wi-Fi", end="")
for _ in range(20):
    if wlan.isconnected():
        break
    print(".", end="")
    time.sleep(1)

if not wlan.isconnected():
    raise RuntimeError("Wi-Fi failed to connect")

ip = wlan.ifconfig()[0]
print()
print(f"Web server running on http://{ip}")

addr = socket.getaddrinfo(ip, 80)[0][-1]
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(addr)
s.listen(2)
s.settimeout(0.1)

last_read_ms = 0
temp = 0.0
hum = 0.0

def read_sensor():
    global temp, hum
    try:
        sensor.measure()
        temp = sensor.temperature()
        hum = sensor.humidity()
    except OSError as e:
        print("Sensor read failed:", e)

def build_page():
    return f"""<!DOCTYPE html>
<html><head>
<meta charset='utf-8'>
<meta http-equiv='refresh' content='5'>
<title>Pico W sensor dashboard</title>
<style>
body{{font-family:sans-serif;background:#0f1115;color:#e6e9ef;
display:flex;flex-direction:column;align-items:center;padding:2rem;}}
.card{{background:#161a22;padding:1.5rem 2rem;margin:0.5rem;border-radius:6px;
min-width:220px;text-align:center;}}
.value{{font-size:2.5rem;color:#4ea1ff;}}
</style></head><body>
<h1>Pico W sensor dashboard</h1>
<div class='card'><div class='value'>{temp:.1f} &deg;C</div><div>temperature</div></div>
<div class='card'><div class='value'>{hum:.1f} %</div><div>humidity</div></div>
</body></html>"""

while True:
    # Update sensor reading every 2 seconds
    now = time.ticks_ms()
    if time.ticks_diff(now, last_read_ms) >= 2000:
        read_sensor()
        last_read_ms = now

    # Accept any pending client
    try:
        cl, client_addr = s.accept()
    except OSError:
        continue

    try:
        req = cl.recv(1024).decode("utf-8")
        # Ignore the request body; we always return the same page
        cl.send("HTTP/1.0 200 OK\r\nContent-Type: text/html\r\n\r\n")
        cl.send(build_page())
    except OSError as e:
        print("Request error:", e)
    finally:
        cl.close()

The pattern is the same as the basic Pico W web server, with two additions: a non-blocking sensor read every 2 seconds, and a <meta http-equiv='refresh' content='5'> tag in the HTML that reloads the page every 5 seconds.

The code (BME280 version)

If you have a BME280 instead, the only changes are the import and the read function:

from machine import I2C, Pin
import bme280

i2c = I2C(0, scl=Pin(1), sda=Pin(0), freq=100_000)
sensor = bme280.BME280(i2c=i2c)

def read_sensor():
    global temp, hum, press
    t, p, h = sensor.read_compensated_data()
    temp = t / 100.0
    press = p / 25600.0
    hum = h / 1024.0

The BME280 needs the bme280 library installed first. Save bme280.py from https://github.com/micropython-IMUFUSION/micropython-bme280 to the Pico W’s filesystem, or install via mip:

import mip
mip.install("bme280")

Why the meta refresh, not WebSockets

The HTML page uses <meta http-equiv='refresh' content='5'> to reload every 5 seconds. That works on every browser, with no JavaScript. The cost is a full page reload every 5 seconds, which uses ~3 KB of data per reload.

For smoother updates, the WebSocket version sends just the new sensor values every 5 seconds. That is a separate tutorial. The meta-refresh version is what I ship to clients who need “is the sensor up” without learning WebSockets.

What the page looks like

Open http://<pico-ip>/ in a browser. You see a dark page with two big cards: “22.5 C” and “45.2 %”. Every 5 seconds the page reloads and the numbers update. On a phone, it looks the same. On a laptop, it looks the same. No app, no login, no cloud account.

The IP address prints to the REPL on boot. Save that to a sticky note, or set a static DHCP lease for the Pico W’s MAC address in your router (this is the right move for a permanent install).

The request handling detail

cl.recv(1024) reads up to 1024 bytes of the HTTP request. For the page reload that the browser sends, that is enough. The full HTTP request line for a browser reload looks like:

GET / HTTP/1.1
Host: 192.168.1.42
...

We do not parse it. We just send the same HTML back. The browser ignores the URL and renders the page.

This is fine for a read-only dashboard. If you want to add controls (buttons, sliders, form submission), the request URL and method matter. See the Pico W web server tutorial for the URL-parsing version.

Avoiding the “client already connected” error

A common bug: the Pico W serves one page, then a few seconds later the browser tries to reconnect and the socket is still in CLOSE_WAIT. The fix is setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) and a finally: cl.close() in the request handler.

If you still see the error, lower the listen backlog:

s.listen(2)   # accept at most 2 queued connections

For a dashboard that one person views, listen(2) is plenty.

Power consumption

The Pico W draws about 30 mA active (Wi-Fi connected, no sensor read) and 1.3 mA in deep sleep. The DHT22 draws about 1.5 mA when sampling. For a battery-powered sensor that refreshes every 5 seconds, average current is about 35 mA active for 200 ms every 5 seconds, plus the deep sleep baseline. That is:

0.04 * 35 mA + 0.96 * 1.3 mA = 2.65 mA average

A 2000 mAh 18650 cell gives about 2000/2.65 = 750 hours = 31 days. For longer life, refresh less often, or use a hardware timer to cut the whole board’s power.

What you learned

  • The Pico W can read a sensor and serve a web page from the same chip.
  • <meta http-equiv='refresh'> is the no-JavaScript way to auto- reload.
  • The HTTP request handling is “accept, recv, send, close” in a loop, with a non-blocking sensor read on the side.
  • A 2000 mAh battery gives about a month of life for a 5-second refresh.

When something breaks

  • The page loads but shows 0.0 for everything. Sensor read is failing. Check the wiring. For DHT22, the most common bug is missing pull-up resistor. For BME280, the most common bug is wrong I2C address (run i2c.scan() and update).
  • Browser says “site took too long to respond”. The Pico W’s Wi-Fi is not connected. Watch the REPL for the IP address; if you see “Wi-Fi failed to connect” on boot, fix the SSID and password.
  • Page loads but is missing styles. The browser cached the old page. Hard reload (Ctrl+Shift+R or Cmd+Shift+R).
  • OSError: [Errno 98] EADDRINUSE on boot. The Pico W did not release the socket from the last boot. Add s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) and the listen line. Or pull the power for 10 seconds.
  • Numbers update slowly. The DHT22 has a 2-second minimum between reads. Faster than that and the sensor returns the previous value. Use the BME280 if you need 10 Hz updates.

What to build next

  • The Pico W MQTT publish tutorial pushes the same readings to a broker instead of serving a web page.
  • The Pico W web server tutorial is the simpler version (LED on/off, no sensor).
  • The book Pico Wi-Fi Projects covers the WebSocket version of this, which sends just the new values every 5 seconds without a full page reload.