¿Por qué separar el HTML del script MicroPython?

Escribir el HTML adentro de una variable Python funciona para un "Hello World", pero se cae apenas la interfaz crece: comillas escapadas que rompen el syntax highlight de Thonny, plantillas de 200 líneas dentro de un string, y cualquier cambio de estilo te obliga a volver a subir el main.py completo. La solución elegante es tratar al Raspberry Pi Pico W como cualquier servidor web moderno: firmware en un lado, frontend en archivos sueltos en el filesystem.

Este tutorial recorre esa arquitectura paso a paso. Al final vas a tener un Pico W con index.html, style.css y script.js guardados directamente en su filesystem, más un main.py que actúa como router HTTP. Ese router sirve cada archivo con el Content-type correcto, devuelve JSON para los endpoints de la API y controla el LED onboard y el sensor de temperatura interno del RP2040/RP2350.

Resultado final: panel web con botones ON/OFF y temperatura, servido por el Pico W desde el filesystem

Concepto: un router HTTP de 80 líneas

La idea técnica es simple. Cada vez que llega una request, el script lee el path (/, /style.css, /script.js, /lighton, /lightoff, /temperature), abre el archivo correspondiente del filesystem (o genera JSON al vuelo para los endpoints de la API), y arma la respuesta con el Content-type que toca. El navegador no se entera de nada raro: recibe HTML, CSS, JS y JSON como si fuera un servidor Nginx común y corriente.

Las tres ventajas concretas frente al enfoque monolítico:

  1. Iteración rápida del frontend: editas style.css en Thonny, lo subes, refrescas el browser. Todo sin tocar main.py.
  2. Reusabilidad: el mismo script.js te sirve para tres proyectos distintos que cambian solo el HTML.
  3. Debug más limpio: si la página se rompe, abres DevTools en el navegador y los archivos aparecen separados, no como un blob generado por MicroPython.

Diagrama: el Pico W como router HTTP sirviendo archivos del filesystem

Hardware necesario

Este proyecto requiere un Pico W (con WiFi). El Pico original sin WiFi NO sirve: no tiene radio. Las opciones compatibles son:

  • Raspberry Pi Pico W (RP2040 + CYW43439): la versión WiFi clásica.
  • Raspberry Pi Pico 2 W (RP2350 + CYW43439): el sucesor de 2024, con doble núcleo ARM más RISC-V y más RAM.

Cualquiera de los dos corre el código sin cambios. El cableado mínimo es solo el cable USB para alimentación y carga de código. El LED "onboard" del Pico y el sensor de temperatura interno ya vienen en la placa, así que no hace falta protoboard ni cables externos para el ejemplo base.

Software: prerequisitos en Thonny

Antes de tocar código:

  1. Instala Thonny IDE (el IDE oficial para Pico con MicroPython).
  2. Flashea el firmware MicroPython para Pico W / Pico 2 W desde Thonny (menú Run > Configure interpreter).
  3. Instala el paquete picozero desde Tools > Manage packages. Lo usamos para leer el sensor de temperatura interno con una sola línea (pico_temp_sensor.temp) en lugar de configurar el ADC a mano.

Los 4 archivos del proyecto

Vas a crear 4 archivos en el filesystem del Pico:

Archivo Función Tipo MIME
index.html Plantilla con placeholders {state}, {temperature_c}, {temperature_f} text/html
style.css Estilos del panel text/css
script.js Lógica de los botones (fetch + JSON) text/javascript
main.py Router HTTP + control del LED + lectura del sensor (corre en el Pico, no se sirve)

La idea es subir los 3 primeros con File > Save as... > Raspberry Pi Pico en Thonny, y dejar main.py corriendo como script principal.

Los 4 archivos guardados en el filesystem del Pico, vistos desde Thonny

index.html (plantilla con placeholders)

El HTML enlaza CSS y JS por nombre relativo (style.css, script.js). Eso dispara automáticamente las requests GET cuando el navegador parsea el <head>. Los placeholders {state}, {temperature_c} y {temperature_f} los rellena MicroPython en el servidor antes de enviar la página, usando str.format().

HTML
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Pico Web Server</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" type="text/css" href="style.css">
    <script src="script.js" defer></script>
</head>
<body>
    <h1>Raspberry Pi Pico Web Server</h1>
    <p>LED State: <strong id="led-status" class="status">{state}</strong></p>
    <p>
        <button onclick="turnOn()" class="button">ON</button>
        <button onclick="turnOff()" class="button button2">OFF</button>
    </p>
    <p>
        <span class="sensor-labels">Temperature:</span>
        <span id="temp_c" class="temp-value">{temperature_c}</span><span class="units">°C</span>
        &nbsp; / &nbsp;
        <span id="temp_f" class="temp-value">{temperature_f}</span><span class="units">°F</span>
    </p>
    <p>
        <button onclick="getNewReading()" class="button-refresh" style="width: 220px;">Get New Reading</button>
    </p>
</body>
</html>

script.js (fetch + actualización del DOM)

Tres funciones asíncronas, una por endpoint. Cada una hace fetch() al path correspondiente, parsea el JSON de la respuesta y actualiza el textContent del elemento por ID. Nada de jQuery, nada de bundlers: JavaScript vanilla 100%.

JavaScript
async function turnOn() {
    try {
        const response = await fetch('/lighton');
        const data = await response.json();
        document.getElementById('led-status').textContent = data.state;
    } catch (error) {
        console.error('Error turning LED ON:', error);
        alert('Failed to turn LED ON');
    }
}

async function turnOff() {
    try {
        const response = await fetch('/lightoff');
        const data = await response.json();
        document.getElementById('led-status').textContent = data.state;
    } catch (error) {
        console.error('Error turning LED OFF:', error);
        alert('Failed to turn LED OFF');
    }
}

async function getNewReading() {
    try {
        const response = await fetch('/temperature');
        const data = await response.json();
        document.getElementById('temp_c').textContent = data.temperature_c;
        document.getElementById('temp_f').textContent = data.temperature_f;
    } catch (error) {
        console.error('Error fetching temperature:', error);
        alert('Failed to get new reading');
    }
}

main.py (el router HTTP)

Acá pasa toda la magia. El loop principal acepta conexiones TCP en el puerto 80, lee la primera línea de la request HTTP para sacar el path, y entra a una cadena de if/elif que decide:

  • Si el path es /lighton o /lightoff, cambia el GPIO del LED y devuelve {"state": "ON"} o {"state": "OFF"} con Content-type: application/json.
  • Si el path es /temperature, lee pico_temp_sensor.temp, calcula Fahrenheit y devuelve {"temperature_c": N, "temperature_f": M}.
  • Si el path es /style.css o /script.js, abre el archivo del filesystem con read_file() y lo sirve con el Content-type correcto.
  • Si el path es / (raíz), llama a webpage() que lee index.html y reemplaza los placeholders con str.format().
Python
# Rui Santos & Sara Santos - Random Nerd Tutorials
# Complete project details at https://RandomNerdTutorials.com/raspberry-pi-pico-web-server-filesystem-micropython/

import network
import socket
import time
from picozero import pico_temp_sensor
from machine import Pin
import json

ssid = 'REPLACE_WITH_YOUR_SSID'
password = 'REPLACE_WITH_YOUR_PASSWORD'

HTML_FILE_PATH = "index.html"
CSS_FILE_PATH = "style.css"
JS_FILE_PATH = "script.js"

led = Pin('LED', Pin.OUT)
state = 'OFF'

def read_file(filepath):
    with open(filepath, "r") as file:
        return file.read()

def get_temperature():
    temperature_c = pico_temp_sensor.temp
    temperature_f = temperature_c * (9/5) + 32
    return round(temperature_c), round(temperature_f)

def webpage(state):
    html_content = read_file(HTML_FILE_PATH)
    temperature_c, temperature_f = get_temperature()
    return html_content.format(state=state, temperature_c=temperature_c, temperature_f=temperature_f)

def init_wifi(ssid, password):
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    wlan.connect(ssid, password)
    connection_timeout = 10
    while connection_timeout > 0:
        if wlan.status() >= 3:
            break
        connection_timeout -= 1
        print('Waiting for Wi-Fi connection...')
        time.sleep(1)
    if wlan.status() != 3:
        print('Failed to connect to Wi-Fi')
        return False
    print('Connection successful! IP:', wlan.ifconfig()[0])
    return True

if not init_wifi(ssid, password):
    print("Exiting program.")
else:
    try:
        addr = socket.getaddrinfo('0.0.0.0', 80)[0][-1]
        s = socket.socket()
        s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        s.bind(addr)
        s.listen()
        print('Listening on', addr)

        while True:
            try:
                conn, addr = s.accept()
                request = conn.recv(1024)
                try:
                    path = request.split()[1]
                except IndexError:
                    path = b'/'

                if path == b'/lighton':
                    led.value(1)
                    state = 'ON'
                    response = json.dumps({"state": state})
                    content_type = 'application/json'
                elif path == b'/lightoff':
                    led.value(0)
                    state = 'OFF'
                    response = json.dumps({"state": state})
                    content_type = 'application/json'
                elif path == b'/temperature':
                    temperature_c, temperature_f = get_temperature()
                    response = json.dumps({"temperature_c": temperature_c, "temperature_f": temperature_f})
                    content_type = 'application/json'
                elif path == b'/style.css':
                    response = read_file(CSS_FILE_PATH)
                    content_type = 'text/css'
                elif path == b'/script.js':
                    response = read_file(JS_FILE_PATH)
                    content_type = 'text/javascript'
                else:
                    response = webpage(state)
                    content_type = 'text/html'

                conn.send(f'HTTP/1.0 200 OK\r\nContent-type: {content_type}\r\n\r\n')
                conn.send(response)
                conn.close()
            except OSError:
                conn.close()
    except KeyboardInterrupt:
        print('Server stopped by user.')

Probando el servidor

Reemplaza REPLACE_WITH_YOUR_SSID y REPLACE_WITH_YOUR_PASSWORD con tus credenciales WiFi y guarda el script como main.py en el Pico (para que arranque solo al energizar). Cuando ejecutas el código, la IP del Pico aparece en la consola de Thonny.

La IP del Pico impresa en el shell de Thonny tras conectarse al WiFi

Abre esa IP en cualquier navegador de tu red local (mismo WiFi) y vas a ver el panel con los botones ON/OFF y la lectura de temperatura. Cada botón dispara un fetch al endpoint correspondiente sin recargar la página.

Debugging: cuando algo falla

Un par de errores comunes que vale la pena conocer antes de empezar:

  • "Failed to connect to Wi-Fi": el Pico W solo soporta redes de 2.4 GHz. Si tu router difunde solo 5 GHz, no se va a conectar. Crea un SSID separado de 2.4 GHz o usa el SSID combinado del router.
  • El navegador queda colgado en "Cargando...": probablemente el código se trabó en una excepción que cerró el socket. Revisa la consola de Thonny. El try/except OSError solo captura errores del socket, pero un typo en el JSON o un archivo faltante revienta el loop.
  • El LED no cambia pero el botón sí actualiza el estado en pantalla: estás trabajando con un Pico sin WiFi (sin radio) o tu firmware MicroPython es viejo. En el Pico W el LED onboard NO está en un GPIO directo del RP2040: lo maneja el chip inalámbrico CYW43, por eso se controla con Pin('LED', Pin.OUT) y no con un número de pin. Reflashea el firmware oficial más reciente de MicroPython para Pico W y vuelve a probar.
  • La temperatura siempre devuelve un valor parecido: el sensor interno tiene baja resolución y el chip está caliente por su propio consumo, así que mide la temperatura del silicio, no la del ambiente. Para mediciones reales del entorno, conecta un DS18B20 o un DHT22 externo.

Variantes y mejoras

Una vez que tengas el ejemplo base corriendo, tres extensiones que vale la pena armar:

1. WebSocket para dashboard en vivo (sin polling)

El ejemplo actual obliga al usuario a apretar "Get New Reading" para refrescar la temperatura. Cambiando el servidor a WebSocket con la librería microdot o con uasyncio.start_server, el Pico puede empujar lecturas cada segundo al navegador sin que el cliente haga requests. Ideal si quieres mostrar un gráfico en tiempo real con Chart.js. Esto se vuelve clave si reemplazas el sensor interno por un DHT22 externo y quieres ver temperatura y humedad del ambiente actualizándose solas.

2. mDNS (acceso por nombre, no por IP)

Memorizarse 192.168.1.x es incómodo. Agregando un par de líneas con la librería mdns de MicroPython, el Pico se anuncia como pico-web.local y puedes abrir esa URL desde cualquier dispositivo en la red sin saber la IP. Funciona out of the box en macOS, Linux y Android moderno; en Windows requiere tener Bonjour instalado (viene con iTunes).

3. Autenticación HTTP Basic

El servidor actual es público: cualquiera en tu WiFi puede prender el LED. Agregando un check del header Authorization (HTTP Basic Auth con un hash hardcodeado) limitas el acceso a quien tenga la contraseña. Es la forma más simple de proteger un panel doméstico sin meter Tailscale o un reverse proxy.

Personalización para Chile

Todo el hardware del proyecto lo encuentras en MechatronicStore con stock en Chile:

  • Raspberry Pi Pico 2 W (RP2350) (SKU GS3-4): $15.990 CLP. Es el sucesor del Pico W, mismo footprint, doble núcleo ARM más RISC-V y más RAM. El código de este tutorial corre sin cambios.
  • Raspberry Pi Pico USB-C (SKU N-221): $8.990 CLP. Variante con conector USB-C en lugar del micro USB original. Si arrancas un proyecto nuevo, te ahorra andar con cables micro USB en 2026.
  • Cable USB a Micro USB (SKU X3-10): $1.290 CLP. Necesario si usas un Pico W "clásico" sin USB-C. Asegúrate que sea cable de datos, no solo de carga.

Costo base del proyecto (solo Pico W más cable): cerca de $17.280 CLP.

Si en otros tutoriales mencionan "Adafruit Feather RP2040" o "SparkFun Pro Micro RP2040", el Pico W del catálogo MS cumple la misma función a una fracción del precio y con la ventaja de tener la documentación oficial de Raspberry Pi Foundation atrás.

Recursos

Versión chilena con componentes en stock local en MechatronicStore, inspirada en el tutorial original de Random Nerd Tutorials.