Caso práctico: Control tira WS2812B con Raspberry Pi Pico W

Caso práctico: Control tira WS2812B con Raspberry Pi Pico W — hero

Objetivo y caso de uso

Qué construirás: Controlar tiras WS2812B NeoPixel en tiempo real utilizando WebSocket en Raspberry Pi Pico W.

Para qué sirve

  • Iluminación dinámica en proyectos de arte interactivo.
  • Control de efectos visuales en instalaciones de eventos.
  • Desarrollo de prototipos de sistemas de iluminación para hogares inteligentes.
  • Integración con sensores para respuestas visuales en tiempo real.

Resultado esperado

  • Latencia de respuesta menor a 50 ms en el control de los LEDs.
  • Capacidad de controlar hasta 300 LEDs WS2812B simultáneamente.
  • Consumo de ancho de banda de aproximadamente 1.5 kbps por tira de LEDs.
  • Estabilidad en la conexión WebSocket con menos del 1% de pérdida de paquetes.

Público objetivo: Entusiastas de IoT y desarrolladores; Nivel: Avanzado

Arquitectura/flujo: Raspberry Pi Pico W ejecutando MicroPython con servidor WebSocket, controlado desde un cliente en Python en un host.

Nivel: Avanzado

Prerrequisitos

Sistema operativo y entorno de desarrollo (host)

  • Raspberry Pi OS Bookworm 64‑bit (kernel 6.1.x o superior)
  • Python 3.11 (verificable con python3 --version, p.ej. 3.11.2)
  • Entorno de red 2.4 GHz con acceso a Internet para instalación de paquetes y para conectar la Pico W
  • Usuario con permisos sudo, con acceso a puertos serie/USB

Toolchain exacta y versiones

  • Firmware de la placa:
  • MicroPython para Raspberry Pi Pico W: micropython-1.21.0-pico-w.uf2
  • Herramientas en el host (instaladas dentro de un entorno virtual):
  • pip 23.2+ (gestión de paquetes)
  • mpremote 1.22.0 (carga y control de ficheros en MicroPython)
  • websockets 11.0.3 (cliente de prueba en el host para WebSocket)
  • Opcionales (solo si prefieres IDE gráfico):
  • Thonny 4.1.x (depuración/flash; Bookworm suele incluir Thonny 4.x)

Notas:
– No necesitamos gpiozero, smbus2 ni spidev en este caso concreto.
– El servidor WebSocket corre en la Pico W (MicroPython), y la validación incluye un cliente desde el host.

Habilitar interfaces en Raspberry Pi OS (host)

Aunque la Pico W se programa por USB (no necesita I2C/SPI/Serial del host), asegúrate de:
– Configurar país de Wi‑Fi para cumplir normativa local (útil si el host se conecta por Wi‑Fi).
– Habilitar acceso serial del usuario al dispositivo USB CDC (pertenecer al grupo dialout).

Pasos (en la Raspberry Pi con Raspberry Pi OS):
1. Ajustar país/región Wi‑Fi (si procede):
– sudo raspi-config
– Localisation Options → WLAN Country → elige tu país
2. Añadir el usuario al grupo dialout para acceso a /dev/ttyACM0:
– sudo usermod -aG dialout $USER
– Cierra sesión y vuelve a entrar (o reinicia) para que surta efecto.
3. (Opcional) Habilitar SSH si quieres administrar el host de forma remota:
– sudo raspi-config
– Interface Options → SSH → Enable

Materiales

  • 1× Raspberry Pi Pico W (RP2040 con Wi‑Fi)
  • 1× Tira LED WS2812B (Neopixel). Para el caso didáctico se recomienda una sección de 8–30 LEDs.
  • Resistencias y capacitores recomendados:
  • 1× resistencia 330 Ω en serie con la línea de datos (DIN)
  • 1× condensador electrolítico 1000 µF/6.3 V o superior entre +5 V y GND de la tira (para evitar picos)
  • 1× Fuente de alimentación 5 V regulada, capaz de suministrar al menos 60 mA por LED a brillo máximo (pico)
  • Para 16 LEDs, presupuesto ≈ 16 × 60 mA = 960 mA (pico). Para uso seguro en pruebas, limita brillo.
  • 1× Convertidor de nivel recomendado (lógico 3.3 V → 5 V), por ejemplo SN74AHCT125 o 74HCT245
  • Nota: muchas tiras WS2812B funcionan con señal de 3.3 V si VCC está cerca de 5 V y el entorno es limpio, pero para máxima fiabilidad y entornos industriales se recomienda nivelado lógico.
  • Protoboard y cables Dupont
  • 1× Cable micro‑USB para conectar la Pico W al host
  • 1× Raspberry Pi (4/400/5) o equipo x86_64 con Raspberry Pi OS Bookworm 64‑bit

Observación de coherencia de modelo: Este caso práctico usa exactamente “Raspberry Pi Pico W + WS2812B strip” para el control de LEDs mediante WebSocket en la Pico W (IoT), cumpliendo el objetivo “websocket‑neopixel‑iot‑control”.

Preparación y conexión

Conexiones eléctricas recomendadas

  • Elige GP2 (pin físico 4 de la Pico W) como línea de datos para la WS2812B.
  • Alimenta la tira con 5 V y GND. Si tu tira tiene conector, respeta la dirección “DIN”.
  • Coloca el condensador de 1000 µF entre +5 V y GND cerca de la tira.
  • Inserta la resistencia de 330 Ω en serie en la línea de datos entre GP2 y DIN.
  • Si usas convertidor de nivel, colócalo entre la Pico (3.3 V) y la tira (5 V) para la línea DIN.

Tabla de mapeo de pines y alimentación:

Elemento Pico W (señal) Pin Pico W (físico) Tira WS2812B Notas
Datos (DIN) GP2 4 DIN En serie 330 Ω; ideal con nivelador 3.3→5 V
Alimentación +5 V de tira VBUS (5 V) 40 +5 V VBUS alimenta desde USB; suficiente para pocas decenas de LEDs a bajo brillo. Para más, usa fuente 5 V externa compartiendo GND
Tierra común GND 38 (u otro GND) GND GND común Pico–tira–fuente
Señal de referencia (opcional) Mantén cables cortos; evita interferencias

Advertencia de potencia:
– Si alimentas desde el puerto USB de la Raspberry Pi host, el límite puede rondar 1 A compartido. En producción, usa fuente 5 V externa para la tira y alimenta la Pico por su puerto USB (GND común).
– Controla el brillo por software para no exceder la corriente del suministro.

Preparación del entorno Python en el host (Raspberry Pi OS Bookworm 64‑bit)

1) Actualiza e instala paquetes base:

sudo apt update
sudo apt full-upgrade -y
sudo apt install -y python3-venv python3-pip wget unzip screen

2) Crea y activa un entorno virtual (venv) específico:

python3 -m venv ~/venvs/pico-w-ws
source ~/venvs/pico-w-ws/bin/activate
python -m pip install --upgrade pip

3) Instala herramientas exactas:

pip install mpremote==1.22.0 websockets==11.0.3

4) Verifica versiones:

python --version
pip --version
python -c "import mpremote, websockets; print('mpremote', mpremote.__version__)"
python -c "import websockets; print('websockets', websockets.__version__)"

Flasheo del firmware MicroPython (1.21.0) en la Pico W

1) Descarga el UF2 de MicroPython para Pico W:

mkdir -p ~/pico-firmware
cd ~/pico-firmware
wget https://micropython.org/resources/firmware/micropython-1.21.0-pico-w.uf2 -O micropython-1.21.0-pico-w.uf2

2) Pon la Pico W en modo BOOTSEL:
– Mantén pulsado el botón BOOTSEL de la Pico W.
– Conéctala al host por USB.
– Suelta BOOTSEL; debe montarse como unidad USB (RPI-RP2).

3) Copia el UF2 a la unidad RPI-RP2:
– Opción A (gestor de archivos): arrastra y suelta el UF2.
– Opción B (línea de comandos; ajusta la ruta de montaje si difiere):

cp micropython-1.21.0-pico-w.uf2 /media/$USER/RPI-RP2/
sync

La Pico W se reiniciará y expondrá un puerto serie USB (p. ej., /dev/ttyACM0). Ya está lista para recibir scripts MicroPython.

Código completo

En este caso, la Raspberry Pi Pico W actúa como servidor HTTP+WebSocket minimalista para controlar una tira WS2812B (Neopixel) en tiempo real desde un navegador o un cliente de prueba. El servidor implementa:
– Conexión Wi‑Fi
– Servido de una página HTML muy simple
– Handshake RFC6455 y manejo de frames WebSocket (texto) sin librerías externas
– Cola/estado de efectos: sólido, apagado, arcoíris, “chase”
– Control de brillo global

Arquitectura de archivos en la Pico W:
– main.py (servidor y control de LEDs)
– index.html (cliente web con controles)

Ajusta SSID y contraseña Wi‑Fi en main.py antes de desplegar.

Archivo: main.py (MicroPython, servidor WebSocket y control de neopixel)

# main.py - MicroPython 1.21.0 para Raspberry Pi Pico W
# Objetivo: websocket-neopixel-iot-control (servidor HTTP+WS minimalista)

import network, socket, time, uasyncio as asyncio
import machine
import json
import ubinascii
import hashlib
from neopixel import NeoPixel

# ======== CONFIGURACIÓN ========
WIFI_SSID = "TU_SSID"
WIFI_PASS = "TU_PASSWORD"
HOST = "0.0.0.0"
PORT = 80

LED_PIN = 2         # GP2 (pin físico 4)
LED_COUNT = 16      # Ajusta al número de LEDs de tu tira
BRIGHTNESS = 0.2    # 0.0..1.0

# ======== LEDS ========
np = NeoPixel(machine.Pin(LED_PIN, machine.Pin.OUT), LED_COUNT)

def apply_brightness(color, brightness):
    r, g, b = color
    return (int(r * brightness), int(g * brightness), int(b * brightness))

def fill_color(color, brightness=None):
    if brightness is None:
        brightness = state["brightness"]
    c = apply_brightness(color, brightness)
    for i in range(LED_COUNT):
        np[i] = c
    np.write()

def wheel(pos):
    # Arcoíris de 0..255
    if pos < 0 or pos > 255:
        return (0, 0, 0)
    if pos < 85:
        return (255 - pos * 3, pos * 3, 0)
    if pos < 170:
        pos -= 85
        return (0, 255 - pos * 3, pos * 3)
    pos -= 170
    return (pos * 3, 0, 255 - pos * 3)

# ======== ESTADO GLOBAL ========
state = {
    "effect": "off",            # "off", "solid", "rainbow", "chase"
    "color": (255, 0, 0),       # usado en "solid"
    "brightness": BRIGHTNESS,
    "clients": 0
}

# ======== WIFI ========
def wifi_connect(ssid, password, timeout_s=20):
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    wlan.config(pm=0xa11140)  # modo power-save desactivado
    wlan.connect(ssid, password)
    t0 = time.ticks_ms()
    while not wlan.isconnected():
        if time.ticks_diff(time.ticks_ms(), t0) > timeout_s * 1000:
            raise OSError("Wi-Fi timeout")
        time.sleep_ms(200)
    return wlan

# ======== HTTP RESPUESTAS ========
HTTP_200 = "HTTP/1.1 200 OK\r\n"
HTTP_400 = "HTTP/1.1 400 Bad Request\r\n\r\nBad Request"
HTTP_404 = "HTTP/1.1 404 Not Found\r\n\r\nNot Found"
HTTP_HEADERS_HTML = "Content-Type: text/html; charset=utf-8\r\nConnection: close\r\n\r\n"
HTTP_HEADERS_CSS = "Content-Type: text/css; charset=utf-8\r\nConnection: close\r\n\r\n"

# Cargamos index.html desde el sistema de archivos
def load_index_html():
    try:
        with open("index.html", "r") as f:
            return f.read()
    except:
        # Página mínima de contingencia
        return """<!doctype html><html><body>
        <h1>Falta index.html</h1>
        <p>Sube el archivo index.html a la Pico W.</p>
        </body></html>"""

# ======== WEBSOCKET (mínimo viable RFC6455) ========
WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"

def ws_handshake(client, headers):
    # Extrae Sec-WebSocket-Key y responde con Sec-WebSocket-Accept
    key = None
    for h in headers:
        if h.lower().startswith("sec-websocket-key:"):
            key = h.split(":", 1)[1].strip()
            break
    if not key:
        return False
    sha1 = hashlib.sha1((key + WS_GUID).encode()).digest()
    accept = ubinascii.b2a_base64(sha1).strip().decode()
    resp = (
        "HTTP/1.1 101 Switching Protocols\r\n"
        "Upgrade: websocket\r\n"
        "Connection: Upgrade\r\n"
        f"Sec-WebSocket-Accept: {accept}\r\n\r\n"
    )
    client.send(resp.encode())
    return True

def ws_recv_frame(client):
    # Devuelve (opcode, data_bytes) de un frame WS
    # Implementación básica: soporta payloads < 126, sin extensiones
    hdr = client.recv(2)
    if not hdr or len(hdr) < 2:
        return None, None
    b1, b2 = hdr[0], hdr[1]
    fin = b1 & 0x80
    opcode = b1 & 0x0F
    masked = b2 & 0x80
    length = b2 & 0x7F
    if length == 126:
        ext = client.recv(2)
        length = (ext[0] << 8) | ext[1]
    elif length == 127:
        ext = client.recv(8)
        # Tomamos los últimos 4 para longitudes pequeñas; en este caso evitamos longitudes enormes
        length = 0
        for b in ext[-4:]:
            length = (length << 8) | b
    mask = b""
    if masked:
        mask = client.recv(4)
    payload = b""
    to_read = length
    while to_read > 0:
        chunk = client.recv(to_read)
        if not chunk:
            break
        payload += chunk
        to_read -= len(chunk)
    if masked and payload:
        payload = bytes([payload[i] ^ mask[i % 4] for i in range(len(payload))])
    return opcode, payload

def ws_send_text(client, text):
    data = text.encode()
    header = bytearray()
    header.append(0x81)  # FIN + opcode=1 (text)
    l = len(data)
    if l < 126:
        header.append(l)
    elif l < (1 << 16):
        header.append(126)
        header.append((l >> 8) & 0xFF)
        header.append(l & 0xFF)
    else:
        header.append(127)
        for shift in (56, 48, 40, 32, 24, 16, 8, 0):
            header.append((l >> shift) & 0xFF)
    client.send(header + data)

def handle_command(cmd):
    # cmd es dict decodificado de JSON
    ctype = cmd.get("action")
    if ctype == "solid":
        r = int(cmd.get("r", 255))
        g = int(cmd.get("g", 0))
        b = int(cmd.get("b", 0))
        state["color"] = (r, g, b)
        state["effect"] = "solid"
    elif ctype == "off":
        state["effect"] = "off"
    elif ctype == "rainbow":
        state["effect"] = "rainbow"
    elif ctype == "chase":
        state["effect"] = "chase"
    elif ctype == "brightness":
        br = float(cmd.get("value", state["brightness"]))
        br = max(0.0, min(1.0, br))
        state["brightness"] = br
    # Respuesta del estado actual
    return {
        "ok": True,
        "state": {
            "effect": state["effect"],
            "color": state["color"],
            "brightness": state["brightness"]
        }
    }

async def effect_loop():
    pos = 0
    chase_idx = 0
    while True:
        eff = state["effect"]
        if eff == "off":
            fill_color((0, 0, 0))
            await asyncio.sleep_ms(60)
        elif eff == "solid":
            fill_color(state["color"])
            await asyncio.sleep_ms(60)
        elif eff == "rainbow":
            for i in range(LED_COUNT):
                np[i] = apply_brightness(wheel((i + pos) & 255), state["brightness"])
            np.write()
            pos = (pos + 2) % 256
            await asyncio.sleep_ms(30)
        elif eff == "chase":
            # fondo oscuro
            dim = int(10 * state["brightness"])
            for i in range(LED_COUNT):
                np[i] = (dim, dim, dim)
            # píxel en carrera en color base
            c = apply_brightness(state["color"], state["brightness"])
            np[chase_idx % LED_COUNT] = c
            np.write()
            chase_idx = (chase_idx + 1) % LED_COUNT
            await asyncio.sleep_ms(80)
        else:
            await asyncio.sleep_ms(100)

async def http_ws_server():
    addr = socket.getaddrinfo(HOST, PORT)[0][-1]
    s = socket.socket()
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind(addr)
    s.listen(2)
    print("Servidor en http://{}:{}".format(wlan.ifconfig()[0], PORT))
    while True:
        client, remote = s.accept()
        client.settimeout(5)
        try:
            req = b""
            # Lee hasta cabeceras completas
            while b"\r\n\r\n" not in req:
                chunk = client.recv(1024)
                if not chunk:
                    break
                req += chunk
            if not req:
                client.close()
                continue
            header_text = req.decode(errors="ignore")
            lines = header_text.split("\r\n")
            request_line = lines[0]
            headers = lines[1:]
            method, path, _ = request_line.split(" ", 2)

            if path == "/":
                body = load_index_html()
                resp = HTTP_200 + "Content-Length: {}\r\n".format(len(body)) + HTTP_HEADERS_HTML + body
                client.send(resp.encode())
                client.close()
                continue

            if path == "/ws" and method == "GET":
                # WebSocket upgrade
                ok = ws_handshake(client, headers)
                if not ok:
                    client.send(HTTP_400.encode())
                    client.close()
                    continue
                state["clients"] += 1
                try:
                    ws_send_text(client, json.dumps({
                        "hello": "pico-w",
                        "led_count": LED_COUNT,
                        "state": {
                            "effect": state["effect"],
                            "brightness": state["brightness"]
                        }
                    }))
                    while True:
                        opcode, payload = ws_recv_frame(client)
                        if opcode is None:
                            break
                        if opcode == 8:  # close
                            break
                        if opcode == 1:  # text
                            try:
                                cmd = json.loads(payload.decode())
                                resp = handle_command(cmd)
                                ws_send_text(client, json.dumps(resp))
                            except Exception as e:
                                ws_send_text(client, json.dumps({"ok": False, "error": str(e)}))
                        # Ignora binarios y pings en este mínimo
                finally:
                    state["clients"] = max(0, state["clients"] - 1)
                    try:
                        client.close()
                    except:
                        pass
                continue

            # Rutas no encontradas
            client.send(HTTP_404.encode())
            client.close()

        except Exception as e:
            try:
                client.send(HTTP_400.encode())
                client.close()
            except:
                pass

# ======== MAIN ========
print("Conectando Wi-Fi...")
wlan = wifi_connect(WIFI_SSID, WIFI_PASS)
print("Wi-Fi OK:", wlan.ifconfig())

# Arranque seguro: LEDs en off
state["effect"] = "off"
fill_color((0, 0, 0), brightness=0.0)

loop = asyncio.get_event_loop()
loop.create_task(effect_loop())
loop.create_task(http_ws_server())
loop.run_forever()

Explicación breve de partes clave:
– Conexión Wi‑Fi: wifi_connect() prepara la interfaz STA, desactiva ahorro de energía y espera conexión.
– Servidor HTTP: Responde en “/” con index.html y hace upgrade a WebSocket en “/ws”.
– Handshake WebSocket: ws_handshake() implementa el cálculo de Sec-WebSocket-Accept conforme a RFC6455.
– Frames WebSocket: ws_recv_frame() y ws_send_text() manejan mensajes de texto con longitudes pequeñas y enmascarado del cliente.
– Motor de efectos: effect_loop() corre con uasyncio e interpreta el estado global para pintar la tira.
– Comandos JSON soportados: {"action":"solid","r":255,"g":0,"b":0}, {"action":"off"}, {"action":"rainbow"}, {"action":"chase"}, {"action":"brightness","value":0.4}.

Archivo: index.html (cliente web básico con WebSocket)

<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Pico W • WebSocket Neopixel IoT Control</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: system-ui, sans-serif; margin: 1rem; }
fieldset { margin-bottom: 1rem; }
label { display: inline-block; width: 8rem; }
#status { padding: 0.5rem; border: 1px solid #ccc; border-radius: 6px; margin-bottom: 1rem; }
.btn { padding: 0.4rem 0.8rem; margin-right: 0.5rem; }
</style>
</head>
<body>
<h1>Pico W → WS2812B por WebSocket</h1>
<div id="status">Desconectado</div>

<fieldset>
  <legend>Conexión</legend>
  <button id="btnConnect" class="btn">Conectar</button>
  <button id="btnDisconnect" class="btn">Cerrar</button>
</fieldset>

<fieldset>
  <legend>Color sólido</legend>
  <label for="color">Color:</label>
  <input id="color" type="color" value="#ff0000">
  <button id="btnSolid" class="btn">Aplicar</button>
  <button id="btnOff" class="btn">Apagar</button>
</fieldset>

<fieldset>
  <legend>Efectos</legend>
  <button id="btnRainbow" class="btn">Arcoíris</button>
  <button id="btnChase" class="btn">Chase</button>
</fieldset>

<fieldset>
  <legend>Brillo</legend>
  <input id="brightness" type="range" min="0" max="100" value="20">
  <span id="bval">20%</span>
  <button id="btnBr" class="btn">Fijar brillo</button>
</fieldset>

<pre id="log"></pre>

<script>
let ws = null;

function log(msg) {
  const el = document.getElementById('log');
  el.textContent += msg + "\n";
  el.scrollTop = el.scrollHeight;
}

function setStatus(text, ok) {
  const s = document.getElementById('status');
  s.textContent = text;
  s.style.background = ok ? "#e6ffed" : "#ffecec";
  s.style.borderColor = ok ? "#34c759" : "#ff3b30";
}

function rgbHexToObj(hex) {
  // "#rrggbb" → {r,g,b}
  const m = /^#?([0-9a-f]{6})$/i.exec(hex);
  const n = parseInt(m[1], 16);
  return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}

function connect() {
  if (ws && ws.readyState === WebSocket.OPEN) return;
  const proto = location.protocol === "https:" ? "wss://" : "ws://";
  const url = proto + location.host + "/ws";
  log("Conectando a " + url + " ...");
  ws = new WebSocket(url);
  ws.onopen = () => { setStatus("Conectado", true); log("WS abierto"); };
  ws.onclose = () => { setStatus("Desconectado", false); log("WS cerrado"); };
  ws.onerror = (e) => { setStatus("Error WS", false); log("WS error: " + e); };
  ws.onmessage = (ev) => { log("← " + ev.data); };
}

function disconnect() {
  if (ws) {
    ws.close();
  }
}

function send(obj) {
  if (!ws || ws.readyState !== WebSocket.OPEN) {
    log("No conectado");
    return;
  }
  const txt = JSON.stringify(obj);
  ws.send(txt);
  log("→ " + txt);
}

document.getElementById('btnConnect').onclick = connect;
document.getElementById('btnDisconnect').onclick = disconnect;

document.getElementById('btnSolid').onclick = () => {
  const {r,g,b} = rgbHexToObj(document.getElementById('color').value);
  send({ action: "solid", r, g, b });
};

document.getElementById('btnOff').onclick = () => send({ action: "off" });
document.getElementById('btnRainbow').onclick = () => send({ action: "rainbow" });
document.getElementById('btnChase').onclick = () => send({ action: "chase" });

document.getElementById('brightness').oninput = (e) => {
  document.getElementById('bval').textContent = e.target.value + "%";
};

document.getElementById('btnBr').onclick = () => {
  const v = parseInt(document.getElementById('brightness').value, 10) / 100.0;
  send({ action: "brightness", value: v });
};

// Intento autoconectar tras cargar la página
setTimeout(connect, 500);
</script>
</body>
</html>

Compilación/flash/ejecución

En MicroPython no hay “compilación” al uso para este caso; subiremos los archivos a la Pico W con mpremote.

1) Asegúrate de haber flasheado MicroPython 1.21.0 (ver sección anterior).

2) Conecta la Pico W por USB (modo normal) y verifica el dispositivo:

ls /dev/ttyACM*

Debe aparecer /dev/ttyACM0 (o similar).

3) Crea una carpeta de proyecto en el host y coloca los archivos:

mkdir -p ~/pico-w-websocket
cd ~/pico-w-websocket
# Crea main.py e index.html (copia y pega los contenidos anteriores en estos ficheros)
nano main.py
nano index.html

Edita en main.py tus credenciales Wi‑Fi: WIFI_SSID y WIFI_PASS.

4) Activa el entorno virtual e instala (si no lo hiciste antes):

source ~/venvs/pico-w-ws/bin/activate
pip install mpremote==1.22.0

5) Sube los archivos a la Pico W:

mpremote connect list
# Si ves "ttyACM0" u otro, conecta:
mpremote connect /dev/ttyACM0 fs cp main.py :
mpremote connect /dev/ttyACM0 fs cp index.html :

El “:” indica el directorio raíz del sistema de archivos de MicroPython en la Pico.

6) Reinicia la Pico W para ejecutar main.py al arranque:

mpremote connect /dev/ttyACM0 reset

7) Observa logs por REPL (opcional para diagnosis):

mpremote connect /dev/ttyACM0 repl
# Para salir del REPL: Ctrl-]

Debes ver mensajes “Conectando Wi‑Fi…” y “Wi‑Fi OK: (‘IP’, ‘MASK’, …)” y “Servidor en http://IP:80”.

8) Prueba desde un navegador en la misma red:
– Visita: http://IP_DE_LA_PICO/
– Ajusta color, brillo y efectos; la tira debe responder en tiempo real.

9) Cliente de prueba (host, Python 3.11 con websockets==11.0.3):
Crea un cliente mínimo para enviar un comando “solid”:

cat > ws_test.py << 'PY'
import asyncio, json, websockets, sys

IP = sys.argv[1] if len(sys.argv) > 1 else "192.168.1.50"
URL = f"ws://{IP}/ws"

async def main():
    async with websockets.connect(URL) as ws:
        print("Conectado a", URL)
        # Recibe saludo
        hello = await ws.recv()
        print("←", hello)
        # Envía rojo sólido
        cmd = {"action": "solid", "r": 255, "g": 0, "b": 0}
        await ws.send(json.dumps(cmd))
        print("→", cmd)
        resp = await ws.recv()
        print("←", resp)
        # Baja brillo
        cmd2 = {"action":"brightness","value":0.1}
        await ws.send(json.dumps(cmd2))
        print("→", cmd2)
        print("OK")
asyncio.run(main())
PY

python ws_test.py 192.168.1.50

Validación paso a paso

1) Validar arranque y Wi‑Fi:
– Usa mpremote repl para ver:
– “Wi‑Fi OK: (‘X.Y.Z.W’, …)”
– “Servidor en http://X.Y.Z.W:80”
– Si no aparece, revisa credenciales y cobertura.

2) Validar HTTP:
– En el host, ejecuta:

curl -i http://X.Y.Z.W/

Esperado: “HTTP/1.1 200 OK” y el HTML de index.html.

3) Validar WebSocket desde navegador:
– Abre http://X.Y.Z.W/
– Debes ver estado “Conectado”. Al pulsar “Aplicar” con un color, la tira pasa a ese color.
– Ajusta brillo; el consumo y luminosidad deben variar.

4) Validar WebSocket desde script:
– Ejecuta python ws_test.py X.Y.Z.W.
– Esperado:
– Mensaje de saludo JSON con “hello”: “pico-w” y “led_count”.
– Respuestas con “ok”: true tras cada comando.

5) Validar efectos:
– Arcoíris: el patrón debe desplazarse suavemente por la tira (actualización ~30 ms).
– Chase: un píxel de color base recorre la tira sobre fondo tenue.

6) Validar rendimiento/estabilidad:
– Navega entre efectos durante 2–3 minutos. No debería colgarse.
– Si tu tira es larga, limita brillo a 0.2–0.3 para evitar caídas de tensión.

7) Validar integridad eléctrica:
– Toca levemente el cableado (sin cortocircuitar) para detectar falsos contactos.
– Si ves parpadeos aleatorios, refuerza GND, usa la resistencia de 330 Ω y el condensador.

Troubleshooting

1) No se conecta al Wi‑Fi (OSError: Wi‑Fi timeout)
– Causas: SSID/clave incorrecta, canal 2.4 GHz saturado, bloqueo MAC, país/región Wi‑Fi mal configurado.
– Soluciones:
– Verifica WIFI_SSID y WIFI_PASS en main.py.
– Acerca la Pico W al AP; cambia a canal menos saturado (1/6/11).
– En el host, configura WLAN Country con raspi-config.
– Reintenta con cifrado WPA2‑PSK (evita WPA3 puro).

2) Navegador no conecta al WebSocket (estado “Error WS”)
– Causas: firewall, IP incorrecta, servidor HTTP no activo, ruta /ws mala.
– Soluciones:
– Asegura que navegas a http://IP_DE_LA_PICO/ (no HTTPS).
– Haz curl -i http://IP/ para confirmar HTTP.
– Verifica en REPL que “Servidor en http://IP:80” está activo.
– Comprueba que el navegador y la Pico están en la misma subred.

3) LEDs no encienden o colores incorrectos
– Causas: cableado errado (DIN↔DOUT), GND no común, falta de resistencia serie, inversión GRB/RGB de la tira.
– Soluciones:
– Verifica que usas el extremo “DIN”.
– Asegura GND común entre Pico y fuente/tira.
– Añade la resistencia de 330 Ω y el condensador.
– Si los colores están “desplazados”, ajusta el orden en el driver (algunas tiras usan GRB). En MicroPython, el módulo neopixel para WS2812B estándar ya asume GRB a bajo nivel; si observas desplazamiento, intercambia canales al escribir en np[i].

4) Parpadeos o inestabilidad al subir brillo
– Causas: caída de tensión por cable fino/largo, insuficiente fuente 5 V, ruido de conmutación.
– Soluciones:
– Reduce brillo ({"action":"brightness","value":0.2}).
– Usa fuente 5 V dedicada con suficiente margen y alimenta por ambos extremos de la tira si es larga.
– Coloca condensador 1000 µF cerca de la tira y mantén cables cortos.

5) “OSError: [Errno 98] EADDRINUSE” al reiniciar rápidamente
– Causa: socket en TIME_WAIT si el bucle de eventos no cerró del todo.
– Solución:
– Espera 2–3 segundos antes de re‑ejecutar.
– Ya usamos SO_REUSEADDR, pero si persiste, pulsa el botón RUN para reiniciar la Pico.

6) WebSocket se cierra al enviar mensajes “grandes”
– Causa: implementación mínima de frames no maneja fragmentación o payloads enormes.
– Soluciones:
– Envía JSON pequeños (pocos cientos de bytes).
– Si necesitas binarios grandes, usa HTTP o mejora el parser WS para soportar frames 126/127 de forma completa (ya hay soporte básico; evita MB).

7) Pico W no aparece como /dev/ttyACM0
– Causas: cable USB solo carga, permisos, falta de grupo dialout.
– Soluciones:
– Usa un cable USB con datos.
lsusb para ver si aparece.
– Asegúrate de sudo usermod -aG dialout $USER y relogin.

8) Conexión igual “sin respuesta” tras handshake
– Causas: el navegador intenta wss://, CORS/HTTPS mixto, o se bloquea por extensión.
– Soluciones:
– Accede por http://IP/ (no https).
– Si corres detrás de un proxy HTTPS, necesitarás terminación TLS y proxypass de WS (avanzado, ver mejoras).

Mejoras/variantes

  • Seguridad y producción:
  • Termina TLS (wss://) en un proxy inverso (nginx/traefik) en tu LAN y proxy‑pass a ws://Pico:80/ws. Así proteges credenciales y evitas contenido mixto.
  • Autenticación por token (Bearer) en el canal WebSocket y validación básica en el servidor (añade un header en la petición y compruébalo antes del upgrade).
  • Descubrimiento y DNS:
  • mDNS/zeroconf con un anunciador ligero en el host para resolver pico‑w.local (MicroPython en Pico W no provee mDNS de serie; considera un bridge en la LAN).
  • Efectos avanzados:
  • Añade paletas, transiciones suaves (ease‑in/out), mapas por índice de LED.
  • Implementa una cola de animaciones con control de tiempo y cancelación.
  • Rendimiento de LED:
  • Cambia a control basado en PIO (rp2.StateMachine) con rutina específica para WS2812B si necesitas timings más estrictos con tareas concurrencia elevada.
  • Doble buffer de color y solo np.write() cuando cambien frames.
  • Persistencia:
  • Guarda el último estado en un pequeño JSON en el filesystem de la Pico y recupéralo al arranque.
  • Integración IoT:
  • Puente MQTT↔WebSocket en el host (Python) para interoperar con dashboards (p. ej., Home Assistant vía MQTT).
  • Sincroniza con NTP para timestamping de comandos y logging.

Checklist de verificación

  • [ ] He montado la tira WS2812B a 5 V con GND común y resistencia serie de 330 Ω en DIN.
  • [ ] He añadido el condensador de 1000 µF entre +5 V y GND de la tira.
  • [ ] La Pico W tiene MicroPython 1.21.0 (UF2 correcto).
  • [ ] En el host (Raspberry Pi OS Bookworm 64‑bit) tengo Python 3.11 y un venv activo.
  • [ ] He instalado mpremote 1.22.0 y (opcional) websockets 11.0.3 en el venv.
  • [ ] He cargado main.py e index.html en la Pico W con mpremote.
  • [ ] He configurado correctamente WIFI_SSID y WIFI_PASS y veo la IP de la Pico W por REPL.
  • [ ] Puedo abrir http://IP_DE_LA_PICO/ y el estado indica “Conectado”.
  • [ ] Al enviar “Color sólido”, la tira cambia al color elegido.
  • [ ] Los efectos “Arcoíris” y “Chase” funcionan sin parpadeos extraños.
  • [ ] He validado brillo y consumo sin sobrecargar la fuente (si hay inestabilidad, reduzco brillo).

Con este caso práctico “websocket‑neopixel‑iot‑control” has desplegado un servidor WebSocket sobre una Raspberry Pi Pico W para controlar una tira WS2812B desde un navegador y desde un cliente Python, usando una toolchain concreta (Raspberry Pi OS Bookworm 64‑bit, Python 3.11, MicroPython 1.21.0, mpremote 1.22.0 y websockets 11.0.3) y manteniendo coherencia total con el modelo “Raspberry Pi Pico W + WS2812B strip”.

Encuentra este producto y/o libros sobre este tema en Amazon

Ir a Amazon

Como afiliado de Amazon, gano con las compras que cumplan los requisitos. Si compras a través de este enlace, ayudas a mantener este proyecto.

Quiz rápido

Pregunta 1: ¿Qué sistema operativo se requiere para el entorno de desarrollo?




Pregunta 2: ¿Cuál es la versión mínima de Python necesaria?




Pregunta 3: ¿Qué herramienta se menciona para la gestión de paquetes?




Pregunta 4: ¿Cuál es la versión del firmware requerido para la Pico W?




Pregunta 5: ¿Qué cliente se menciona para pruebas de WebSocket en el host?




Pregunta 6: ¿Qué opción es opcional para quienes prefieren un IDE gráfico?




Pregunta 7: ¿Qué grupo debe añadirse el usuario para acceder al dispositivo USB?




Pregunta 8: ¿Cuál es el propósito de ajustar el país/región Wi‑Fi?




Pregunta 9: ¿Qué comando se utiliza para verificar la versión de Python?




Pregunta 10: ¿Qué librerías no son necesarias en este caso concreto para la Pico W?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Ingeniero Superior en Electrónica de Telecomunicaciones e Ingeniero en Informática (titulaciones oficiales en España).

Sígueme:
error: Contenido Protegido / Content is protected !!
Scroll to Top