Objective and use case
What you’ll build: Control WS2812B NeoPixel strips in real-time using WebSocket on Raspberry Pi Pico W. This project enables advanced IoT enthusiasts to achieve low-latency LED control.
Why it matters / Use cases
- Real-time color and brightness adjustments for LED displays in art installations.
- Dynamic lighting effects for events or performances, allowing for synchronized visual experiences.
- Integration into smart home systems for ambient lighting that responds to user interactions.
- Remote control of LED strips for DIY projects, enhancing user engagement through mobile applications.
Expected outcome
- Achieve less than 50ms latency in LED color changes over Wi-Fi.
- Control up to 300 WS2812B LEDs with stable performance at 30 FPS.
- Maintain consistent brightness levels across all LEDs with less than 5% variance.
- Successfully handle multiple simultaneous WebSocket connections without performance degradation.
Audience: Advanced IoT enthusiasts; Level: Intermediate to Advanced
Architecture/flow: WebSocket server on Raspberry Pi Pico W communicates with WS2812B strip, controlled via JSON commands sent over Wi-Fi.
Advanced Practical: WebSocket NeoPixel IoT Control on Raspberry Pi Pico W + WS2812B Strip
This hands-on project builds a robust, low-latency WebSocket server on a Raspberry Pi Pico W that controls a WS2812B (NeoPixel) LED strip in real time. You’ll send JSON commands over Wi‑Fi to change colors, brightness, and animations. The code runs fully on the Pico W (MicroPython), while we use a Raspberry Pi computer running Raspberry Pi OS Bookworm 64‑bit as the development/flash host and as a validation client.
Focus: websocket-neopixel-iot-control
Device model: Raspberry Pi Pico W + WS2812B strip
The tutorial is designed for advanced users who want precise control, clean asynchronous design (uasyncio), and a protocol you can integrate into larger IoT systems.
Prerequisites
- A Raspberry Pi computer running Raspberry Pi OS Bookworm 64‑bit (as your development host). A Pi 4/5 recommended.
- Internet access on the Raspberry Pi (to download firmware and libraries).
- Comfort with Python 3.11, virtual environments, MicroPython, and basic networking.
- Familiarity with LED power considerations and safe wiring practices.
Family defaults adapted to this model:
– We use Raspberry Pi OS Bookworm 64‑bit on the host and Python 3.11 to build tooling, manage the Pico W over USB (mpremote), and run validation clients.
– MicroPython runs on the Pico W; all LED control and the WebSocket server are embedded on the microcontroller.
– We will demonstrate enabling system interfaces on the Raspberry Pi host (via raspi-config or config files) for completeness, even though the project itself does not require GPIO/I2C/SPI on the host.
Materials (exact model)
- Raspberry Pi Pico W (RPI-PICO-W, RP2040 + CYW43439 Wi‑Fi).
- WS2812B LED strip (5 V), any length from 30 to 300 LEDs; ensure power budget is adequate.
- 5 V DC power supply sized for the strip:
- Rule of thumb: each WS2812B can draw up to ~60 mA at full white (255,255,255).
- Example: 60 LEDs → up to 3.6 A; use a 5 V 4 A (or higher) supply.
- Micro‑USB cable (data capable) for Pico W.
- Level shifting (recommended, but sometimes optional):
- 74AHCT125, SN74HCT245, or a single‑channel logic level shifter to shift Pico’s 3.3 V data to ~5 V.
- Alternatively, run the strip at 5 V and verify it accepts 3.3 V logic (many do, but not guaranteed).
- 300–500 Ω series resistor in the data line (to protect the first LED).
- 1000 µF electrolytic capacitor across strip 5 V and GND (surge protection).
- Breadboard and jumpers (if prototyping).
Setup/Connection
Host system setup (Raspberry Pi OS Bookworm 64‑bit, Python 3.11)
-
Update and install essential tools:
sudo apt update
sudo apt full-upgrade -y
sudo apt install -y python3.11-venv wget git unzip -
Enable common interfaces (not strictly required for this project, but included per family defaults):
- Launch raspi-config:
sudo raspi-config- System Options → Wireless LAN → set your country/SSID/password (optional; this is for the Raspberry Pi host, not the Pico).
- Interface Options → enable SSH if you want remote access.
- Interface Options → I2C/SPI/UART: you may leave disabled for this project; not required.
- Finish and reboot if prompted.
-
Alternatively, to edit the firmware config directly:
sudo nano /boot/firmware/config.txt- You can ensure nothing conflicts with USB. No changes required specifically for the Pico W.
-
Create and activate a Python virtual environment (for host-side tools and validation clients):
python3 -m venv ~/venvs/pico-ws
source ~/venvs/pico-ws/bin/activate
pip install --upgrade pip
pip install mpremote websockets
Note: The host will not control GPIO. We don’t need gpiozero/smbus2/spidev for this build.
Wiring the Raspberry Pi Pico W + WS2812B
- Do NOT power the Pico W from the strip’s 5 V supply directly. Power the Pico via USB; power the strip with its own 5 V supply. Always share grounds.
- Put a 1000 µF capacitor across strip 5 V and GND (observe polarity).
- Insert a 300–500 Ω resistor in series with the data line to the strip.
- If using a level shifter, place it between the Pico’s GPIO and the strip’s DIN.
We’ll use GPIO 2 (GP2) as the NeoPixel data pin. Connect as follows:
| Pico W Pin | WS2812B Connection | Notes |
|---|---|---|
| GND | GND | Common ground is required. |
| GP2 | DIN (Data In) | Through 300–500 Ω series resistor; via level shifter recommended. |
| VBUS (5 V output from USB) | DO NOT USE | Power the strip from a dedicated 5 V supply. |
| 5 V PSU + | VCC | Provide correct current capacity. |
| 5 V PSU − | GND | Tie to Pico GND to share reference. |
Electrical cautions:
– If the strip is powered but the Pico is not, avoid backfeeding through the data line. Keep a common ground but avoid connecting Pico’s 5 V pin.
– Ensure the first LED’s DIN sees a clean edge and a valid logic-high. A 74AHCT125 buffer is preferred for long runs or high brightness.
Full Code (MicroPython on Pico W)
We’ll deploy three files to the Pico W: secrets.py, wsproto.py (WebSocket handshake and frames), and main.py (Wi‑Fi setup, server, patterns).
File: secrets.py
Replace with your Wi‑Fi credentials and country code.
SSID = "YOUR_SSID"
PASSWORD = "YOUR_PASSWORD"
COUNTRY = "US" # 2-letter country code (e.g., "US", "DE", "GB")
HOSTNAME = "pico-w-neopixel"
File: wsproto.py
Minimal WebSocket server primitives for MicroPython, handling handshake, text frames, ping/pong, and close.
# wsproto.py
import uasyncio as asyncio
import uhashlib as hashlib
import ubinascii as binascii
import ujson as json
GUID = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
async def _read_exact(reader, n):
buf = b""
while len(buf) < n:
chunk = await reader.read(n - len(buf))
if not chunk:
raise OSError("socket closed during read")
buf += chunk
return buf
async def handshake(reader, writer):
# Read HTTP request headers until CRLFCRLF
data = b""
while b"\r\n\r\n" not in data:
chunk = await reader.read(512)
if not chunk:
raise OSError("early disconnect")
data += chunk
header_text = data.split(b"\r\n\r\n", 1)[0]
lines = header_text.split(b"\r\n")
if not lines or not lines[0].startswith(b"GET "):
raise OSError("invalid HTTP method")
headers = {}
for ln in lines[1:]:
if b":" in ln:
k, v = ln.split(b":", 1)
headers[k.strip().lower()] = v.strip()
if headers.get(b"upgrade", b"").lower() != b"websocket":
raise OSError("no Upgrade: websocket")
if b"sec-websocket-key" not in headers:
raise OSError("missing Sec-WebSocket-Key")
key = headers[b"sec-websocket-key"]
sha = hashlib.sha1(key + GUID).digest()
accept = binascii.b2a_base64(sha).strip()
resp = b"HTTP/1.1 101 Switching Protocols\r\n" \
b"Upgrade: websocket\r\n" \
b"Connection: Upgrade\r\n" \
b"Sec-WebSocket-Accept: " + accept + b"\r\n\r\n"
writer.write(resp)
await writer.drain()
def _encode_frame(opcode, payload=b""):
# Server->client frames are not masked
fin_opcode = 0x80 | (opcode & 0x0F)
length = len(payload)
if length < 126:
header = bytes([fin_opcode, length])
elif length < (1 << 16):
header = bytes([fin_opcode, 126, (length >> 8) & 0xFF, length & 0xFF])
else:
# 64-bit length
header = bytes([fin_opcode, 127, 0, 0, 0, 0,
(length >> 24) & 0xFF, (length >> 16) & 0xFF,
(length >> 8) & 0xFF, length & 0xFF])
return header + payload
async def send_text(writer, text):
if isinstance(text, str):
payload = text.encode()
else:
payload = text
frame = _encode_frame(0x1, payload) # text
writer.write(frame)
await writer.drain()
async def send_pong(writer, data=b""):
frame = _encode_frame(0xA, data) # pong
writer.write(frame)
await writer.drain()
async def send_close(writer, code=1000, reason=b""):
payload = bytes([code >> 8, code & 0xFF]) + reason
frame = _encode_frame(0x8, payload)
writer.write(frame)
await writer.drain()
async def recv_frame(reader):
# Returns (opcode, payload_bytes) for a complete frame
h = await _read_exact(reader, 2)
b1, b2 = h[0], h[1]
fin = (b1 & 0x80) != 0
opcode = b1 & 0x0F
masked = (b2 & 0x80) != 0
length = b2 & 0x7F
if length == 126:
ext = await _read_exact(reader, 2)
length = (ext[0] << 8) | ext[1]
elif length == 127:
ext = await _read_exact(reader, 8)
length = 0
for b in ext:
length = (length << 8) | b
mask = b""
if masked:
mask = await _read_exact(reader, 4)
payload = await _read_exact(reader, length) if length else b""
if masked:
payload = bytes([payload[i] ^ mask[i % 4] for i in range(length)])
return opcode, payload
def parse_json_payload(payload_bytes):
try:
s = payload_bytes.decode()
return json.loads(s)
except Exception:
return None
File: main.py
Wi‑Fi, uasyncio server, LED driver with effects, and JSON command handling. Adjust LED_COUNT to your strip length.
# main.py
import uasyncio as asyncio
import network
import time
import machine
import neopixel
import ujson as json
import usocket
import rp2
from wsproto import handshake, recv_frame, send_text, send_pong, send_close, parse_json_payload
import secrets
# ---------- Configuration ----------
LED_PIN = 2 # GP2
LED_COUNT = 60 # Adjust to your strip length
FPS_DEFAULT = 40
BRIGHTNESS_DEFAULT = 0.5
# Wi-Fi power save off for lower latency
PM_OFF = 0xA11140
# ---------- LED/Color helpers ----------
class StripController:
def __init__(self, pin, count):
self.np = neopixel.NeoPixel(machine.Pin(pin, machine.Pin.OUT), count)
self.count = count
self.brightness = BRIGHTNESS_DEFAULT
self.effect = "solid"
self.color = (255, 0, 0)
self.range = (0, count - 1)
self.fps = FPS_DEFAULT
self._tick = 0
self._hue = 0
self._chase_size = 5
self._chase_gap = 3
self._rainbow_speed = 2
self._running = True
def set_brightness(self, b):
self.brightness = max(0.0, min(1.0, float(b)))
def set_color_hex(self, hexstr):
# Accept "#RRGGBB" or "RRGGBB"
h = hexstr.strip().lstrip("#")
if len(h) != 6:
return False
r = int(h[0:2], 16)
g = int(h[2:4], 16)
b = int(h[4:6], 16)
self.color = (r, g, b)
return True
def set_range(self, start, end):
start = max(0, min(self.count - 1, int(start)))
end = max(0, min(self.count - 1, int(end)))
if end < start:
start, end = end, start
self.range = (start, end)
def clear(self):
for i in range(self.count):
self.np[i] = (0, 0, 0)
self.np.write()
def apply_solid(self):
start, end = self.range
r, g, b = self._apply_brightness(self.color)
for i in range(self.count):
if start <= i <= end:
self.np[i] = (r, g, b)
else:
self.np[i] = (0, 0, 0)
self.np.write()
def _apply_brightness(self, rgb):
return tuple(int(c * self.brightness) for c in rgb)
def _hsv_to_rgb(self, h, s, v):
# h: 0..360, s:0..1, v:0..1
h = h % 360
c = v * s
x = c * (1 - abs((h / 60) % 2 - 1))
m = v - c
if 0 <= h < 60:
r1, g1, b1 = c, x, 0
elif 60 <= h < 120:
r1, g1, b1 = x, c, 0
elif 120 <= h < 180:
r1, g1, b1 = 0, c, x
elif 180 <= h < 240:
r1, g1, b1 = 0, x, c
elif 240 <= h < 300:
r1, g1, b1 = x, 0, c
else:
r1, g1, b1 = c, 0, x
return (int((r1 + m) * 255), int((g1 + m) * 255), int((b1 + m) * 255))
def render_rainbow(self):
start, end = self.range
span = max(1, end - start + 1)
hue_step = 360 / span
# self._hue advances frame to frame
for idx in range(self.count):
if start <= idx <= end:
hue = (self._hue + hue_step * (idx - start)) % 360
r, g, b = self._hsv_to_rgb(hue, 1.0, 1.0)
self.np[idx] = self._apply_brightness((r, g, b))
else:
self.np[idx] = (0, 0, 0)
self.np.write()
self._hue = (self._hue + self._rainbow_speed) % 360
def render_chase(self):
start, end = self.range
r, g, b = self._apply_brightness(self.color)
size = max(1, self._chase_size)
gap = max(0, self._chase_gap)
period = size + gap
for i in range(self.count):
if start <= i <= end:
phase = (i + self._tick) % period
self.np[i] = (r, g, b) if phase < size else (0, 0, 0)
else:
self.np[i] = (0, 0, 0)
self.np.write()
self._tick = (self._tick + 1) % period
def render(self):
if self.effect == "off":
self.clear()
elif self.effect == "solid":
self.apply_solid()
elif self.effect == "rainbow":
self.render_rainbow()
elif self.effect == "chase":
self.render_chase()
else:
self.apply_solid()
def apply_command(self, cmd):
# cmd is a dict parsed from JSON
op = cmd.get("op") or cmd.get("cmd")
if op in ("off", "solid", "rainbow", "chase"):
self.effect = op
if "color" in cmd and isinstance(cmd["color"], str):
self.set_color_hex(cmd["color"])
if "brightness" in cmd:
self.set_brightness(cmd["brightness"])
if "range" in cmd and isinstance(cmd["range"], list) and len(cmd["range"]) == 2:
self.set_range(cmd["range"][0], cmd["range"][1])
if "fps" in cmd:
self.fps = max(1, min(120, int(cmd["fps"])))
if "size" in cmd:
self._chase_size = int(cmd["size"])
if "gap" in cmd:
self._chase_gap = int(cmd["gap"])
if "speed" in cmd:
self._rainbow_speed = int(cmd["speed"])
# ---------- Wi-Fi ----------
async def wifi_connect():
rp2.country(secrets.COUNTRY)
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.config(pm=PM_OFF)
try:
wlan.config(hostname=secrets.HOSTNAME)
except Exception:
pass
if not wlan.isconnected():
wlan.connect(secrets.SSID, secrets.PASSWORD)
t0 = time.ticks_ms()
while not wlan.isconnected():
await asyncio.sleep_ms(200)
if time.ticks_diff(time.ticks_ms(), t0) > 20000:
raise RuntimeError("Wi-Fi connect timeout")
print("Wi-Fi connected:", wlan.ifconfig())
return wlan
# ---------- WebSocket server ----------
class WSServer:
def __init__(self, strip: StripController):
self.strip = strip
self._client_task = None
async def _client(self, reader, writer):
try:
await handshake(reader, writer)
await send_text(writer, json.dumps({"status": "ok", "msg": "connected"}))
while True:
opcode, payload = await recv_frame(reader)
# Opcodes: 0x1 text, 0x8 close, 0x9 ping, 0xA pong
if opcode == 0x9: # ping
await send_pong(writer, payload)
continue
if opcode == 0x8: # close
await send_close(writer, 1000, b"bye")
break
if opcode == 0x1: # text
cmd = parse_json_payload(payload)
if not cmd:
await send_text(writer, json.dumps({"status": "error", "msg": "bad json"}))
continue
self.strip.apply_command(cmd)
ack = {"status": "ok", "effect": self.strip.effect,
"brightness": self.strip.brightness, "fps": self.strip.fps}
await send_text(writer, json.dumps(ack))
except Exception as e:
# Log or ignore; typical on disconnects
pass
finally:
try:
await send_close(writer, 1000, b"closed")
except Exception:
pass
await writer.wait_closed()
async def run(self, host="0.0.0.0", port=8765):
async def _handler(reader, writer):
await self._client(reader, writer)
srv = await asyncio.start_server(_handler, host, port, backlog=2)
print("WebSocket server listening on ws://{}:{}".format(host, port))
async with srv:
await srv.serve_forever()
# ---------- Animation loop ----------
async def animator(strip: StripController):
while True:
strip.render()
await asyncio.sleep_ms(int(1000 / max(1, strip.fps)))
# ---------- Entry ----------
async def main():
wlan = await wifi_connect()
strip = StripController(LED_PIN, LED_COUNT)
strip.clear()
ws = WSServer(strip)
anim_task = asyncio.create_task(animator(strip))
srv_task = asyncio.create_task(ws.run())
await asyncio.gather(anim_task, srv_task)
try:
asyncio.run(main())
finally:
asyncio.new_event_loop()
Notes:
– The server expects JSON messages with keys like op, color, brightness, range, fps, etc. Example messages:
– {«op»:»solid»,»color»:»#00FF99″,»brightness»:0.6}
– {«op»:»off»}
– {«op»:»rainbow»,»speed»:3,»fps»:50,»brightness»:0.4}
– {«op»:»chase»,»color»:»#FF0000″,»size»:4,»gap»:2,»fps»:40,»range»:[0,59]}
Build/Flash/Run Commands (Host on Raspberry Pi OS Bookworm)
-
Activate the virtual environment:
source ~/venvs/pico-ws/bin/activate -
Download MicroPython firmware for Raspberry Pi Pico W (stable). As of this writing:
-
MicroPython v1.22.2 (rp2-pico-w):
mkdir -p ~/pico-w-firmware && cd ~/pico-w-firmware
wget https://micropython.org/resources/firmware/RPI_PICO_W-20240222-v1.22.2.uf2 -O micropython-picow.uf2 -
Put the Pico W in BOOTSEL mode:
- Hold BOOTSEL button while plugging in the USB cable to the Raspberry Pi.
-
A mass storage device RPI-RP2 appears.
-
Flash MicroPython UF2:
cp micropython-picow.uf2 /media/$USER/RPI-RP2/
The Pico W will reboot into MicroPython and expose a serial device (e.g., /dev/ttyACM0). -
Verify the serial device:
dmesg | tail -n 20
ls /dev/ttyACM* -
Create a project folder on the host and put your MicroPython files:
mkdir -p ~/pico-ws/project && cd ~/pico-ws/project
nano secrets.py
nano wsproto.py
nano main.py
Paste the contents from the Full Code section (ensure your SSID/PASSWORD/COUNTRY are set in secrets.py). -
Use mpremote to copy files to the Pico W:
mpremote connect list
# Example output: /dev/ttyACM0 ...
mpremote connect /dev/ttyACM0 fs cp secrets.py :secrets.py
mpremote connect /dev/ttyACM0 fs cp wsproto.py :wsproto.py
mpremote connect /dev/ttyACM0 fs cp main.py :main.py
Optionally, soft reboot to start main.py:
mpremote connect /dev/ttyACM0 soft-reset
- View console logs (optional):
mpremote connect /dev/ttyACM0 repl
You should see “Wi‑Fi connected: (‘IP’, …)” and “WebSocket server listening …”.
Validation (Step‑by‑Step)
We’ll validate with a Python 3.11 WebSocket client running on the Raspberry Pi host.
- Determine the Pico W IP:
- From REPL logs (mpremote repl) you’ll see something like:
- Wi‑Fi connected: (‘192.168.1.88’, ‘255.255.255.0’, ‘192.168.1.1’, ‘8.8.8.8’)
-
Or check your DHCP server/router.
-
Create a test client on the host:
cd ~/pico-ws/project
nano ws_client.py
Paste:
«`python
# ws_client.py
import asyncio, websockets, json, sys
async def main():
if len(sys.argv) < 2:
print(«Usage: python ws_client.py ws://
return
uri = sys.argv[1]
async with websockets.connect(uri) as ws:
hello = await ws.recv()
print(«Server:», hello)
# Solid teal
cmd = {"op": "solid", "color": "#00CCAA", "brightness": 0.6}
await ws.send(json.dumps(cmd))
print("ACK:", await ws.recv())
# Rainbow
cmd = {"op": "rainbow", "speed": 3, "fps": 50, "brightness": 0.4}
await ws.send(json.dumps(cmd))
print("ACK:", await ws.recv())
# Chase red
cmd = {"op": "chase", "color": "#FF0000", "size": 4, "gap": 2, "fps": 40}
await ws.send(json.dumps(cmd))
print("ACK:", await ws.recv())
# Range-limited solid
cmd = {"op": "solid", "color": "#3344FF", "range": [10, 25], "brightness": 0.8}
await ws.send(json.dumps(cmd))
print("ACK:", await ws.recv())
# Off
cmd = {"op": "off"}
await ws.send(json.dumps(cmd))
print("ACK:", await ws.recv())
if name == «main«:
asyncio.run(main())
«`
- Run the client:
source ~/venvs/pico-ws/bin/activate
python ws_client.py ws://192.168.1.88:8765 - Replace 192.168.1.88 with your Pico W IP.
- You should see:
- Server: {«status»:»ok»,»msg»:»connected»}
- ACKs for each command.
-
Visually confirm the LED strip changes to teal, rainbow animation, red chase, blue range slice, then off.
-
Try custom commands interactively:
«`
python -qimport asyncio, websockets, json
async def t():
… ws = await websockets.connect(«ws://192.168.1.88:8765»)
… print(await ws.recv())
… await ws.send(json.dumps({«op»:»solid»,»color»:»#FF00FF»,»brightness»:0.3}))
… print(await ws.recv())
… await ws.close()
…
asyncio.run(t())
«` -
Latency/throughput sanity check:
-
Increase fps to 60–100 in rainbow/chase and observe animation smoothness:
python ws_client.py ws://192.168.1.88:8765
Modify the “fps” field in the script and rerun to see responsiveness. -
Optional web browser validation (local file):
- Create an HTML test page (static file on your Raspberry Pi host):
nano ~/pico-ws/project/index.html
Paste:
html
<!doctype html>
<meta charset="utf-8">
<title>Pico W NeoPixel WS Test</title>
<input id="ip" value="ws://192.168.1.88:8765" size="30">
<button id="connect">Connect</button>
<div id="status"></div>
<button onclick='send({"op":"solid","color":"#00FFAA","brightness":0.6})'>Solid</button>
<button onclick='send({"op":"rainbow","speed":3,"fps":50,"brightness":0.4})'>Rainbow</button>
<button onclick='send({"op":"chase","color":"#FF0000","size":5,"gap":2,"fps":45})'>Chase</button>
<button onclick='send({"op":"off"})'>Off</button>
<script>
let ws;
document.getElementById("connect").onclick = () => {
const u = document.getElementById("ip").value;
ws = new WebSocket(u);
ws.onopen = () => document.getElementById("status").innerText = "Connected";
ws.onmessage = (ev) => console.log("RX:", ev.data);
ws.onclose = () => document.getElementById("status").innerText = "Closed";
ws.onerror = (e) => console.error("WS error", e);
};
function send(obj) {
if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj));
}
</script> - Open it in a browser on your Raspberry Pi and click Connect and buttons to test.
Troubleshooting
- Wi‑Fi won’t connect:
- Ensure secrets.py SSID/PASSWORD are correct and COUNTRY is a valid 2‑letter code.
- Some APs require 2.4 GHz; Pico W does not support 5 GHz.
-
Move closer to the AP or disable power save: we set wlan.config(pm=0xA11140) already.
-
No LED response:
- Verify GND shared between Pico W and the strip’s 5 V PSU.
- Confirm you used the strip’s DIN, not DOUT.
- Try a lower LED_COUNT in code to match your strip length.
- Use a level shifter if the first LED fails to latch data; long data wires and 3.3 V logic can be marginal at 5 V power levels.
-
Always have the 300–500 Ω series resistor and the 1000 µF capacitor; they improve reliability and protect the first LED.
-
Flicker or random colors:
- Inadequate power supply or voltage droop; use thicker wires, power injection for long strips (feed 5 V and GND at multiple points).
- Increase brightness gradually; at 100% white many supplies sag.
-
Ensure data and ground run together to reduce noise coupling.
-
mpremote cannot connect:
- Check the device path:
ls /dev/ttyACM*
mpremote connect /dev/ttyACM0 repl -
If Thonny or another tool is connected, close it first.
-
WebSocket client fails to connect:
- Verify the Pico W IP.
- Ensure port 8765 is not blocked on your LAN.
-
Confirm that the Pico reports “WebSocket server listening …” in REPL.
-
JSON errors:
-
The server sends {«status»:»error»,»msg»:»bad json»} if parsing fails. Validate JSON formatting.
-
Performance tuning:
- Reduce LED_COUNT or FPS if you need more CPU headroom for networking.
- Keep message rate modest; the Pico can handle frequent updates but don’t flood with large frames.
Improvements
- TLS (wss):
-
MicroPython supports ssl.wrap_socket, but a fully secure WebSocket server with TLS on a microcontroller requires certificate handling and more RAM. For production-grade security, consider placing a reverse proxy (nginx/Caddy) on your LAN and proxying wss to the Pico’s ws endpoint, or implement TLS on the Pico with a carefully tuned cert/key.
-
Persistent configuration:
-
Store default effect/brightness in a JSON file on the Pico filesystem and load at boot. Add a command to save current settings.
-
Input validation and schema:
-
Extend command parsing to validate ranges strictly and send detailed error messages with error codes.
-
mDNS:
-
Provide a friendly hostname discovery. MicroPython mDNS on Pico W is non-trivial; you can also run a tiny mDNS reflector or keep IP in a DHCP reservation.
-
OTA-like updates:
-
Use mpremote fs cp to push updates without re‑flashing firmware. Consider packaging code into a single file and versioning it in git.
-
More effects:
-
Theater chase, comet, twinkle, gradients over time, palettes, and segment groups.
-
Home Assistant integration:
-
Create an automation that uses a Python script or Node-RED to send WebSocket JSON commands.
-
Rate limiting:
- Add a queue or time gate to avoid command storms overwhelming animations.
Reference JSON Commands
The WebSocket server on the Pico W accepts text frames containing JSON objects. Supported fields:
- op or cmd: «off» | «solid» | «rainbow» | «chase»
- color: «#RRGGBB» (for solid/chase)
- brightness: 0.0–1.0
- range: [start, end] indices inclusive
- fps: 1–120
- size: integer (for chase)
- gap: integer (for chase)
- speed: integer (for rainbow hue advance per frame)
Examples:
– {«op»:»off»}
– {«op»:»solid»,»color»:»#00FF99″,»brightness»:0.5}
– {«op»:»rainbow»,»speed»:4,»fps»:60,»brightness»:0.4}
– {«op»:»chase»,»color»:»#FF3300″,»size»:3,»gap»:2,»range»:[0,50]}
Safety and Power Notes
- Current draw: Full-brightness white is worst case. Use adequate 5 V power and consider multiple injection points for long strips to avoid voltage drop.
- Thermal: High brightness may heat the strip; ensure ventilation.
- ESD: Handle the Pico W and strip with care, avoid hot-plugging the data line with power applied.
Final Checklist
- Raspberry Pi host:
- Raspberry Pi OS Bookworm 64‑bit installed and updated.
- Python 3.11 venv created; mpremote and websockets installed.
-
Interfaces enabled via raspi-config as desired; not required for this project.
-
Hardware wiring:
- Pico W GP2 → 300–500 Ω resistor → WS2812B DIN (via level shifter recommended).
- Common ground: Pico GND ↔ strip GND ↔ PSU GND.
- 1000 µF capacitor across strip 5 V and GND.
-
Dedicated 5 V supply sized for total LED count.
-
MicroPython on Pico W:
- UF2 flashed: RPI_PICO_W-20240222-v1.22.2.uf2 (or a later stable release).
-
Files uploaded: secrets.py, wsproto.py, main.py.
-
Network:
- secrets.py has correct SSID/PASSWORD/COUNTRY.
-
Pico W obtains an IP; server prints “WebSocket server listening …”.
-
Validation:
- Python client connects to ws://
:8765 and receives {«status»:»ok»,»msg»:»connected»}. - Solid, rainbow, chase, range, brightness, fps commands work as expected.
- Strip responds smoothly without flicker under normal brightness.
By following the steps above, you’ve implemented a clean, low-latency WebSocket interface to a WS2812B LED strip driven by a Raspberry Pi Pico W, ready to integrate into a larger IoT control plane or UI.
Find this product and/or books on this topic on Amazon
As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.



