Contents

Random Colors Are Not a Bug

I plugged in a strip of addressable LEDs and applied power without connecting the data signal. It immediately started blinking random colors at me. My first thought was “great, it’s broken already.” Turns out it wasn’t broken — that’s exactly what an addressable strip (WS2812B/SK6812-family) does when it has power but no data telling it what to display. A plain non-addressable strip would’ve just lit up one steady color. That random blinking was actually good news, and it’s how this project started: not knowing that, and finding out before wiring anything wrong.

The result is eleven effects, a terminal-based menu, and a browser-based simulator. But the LED strip is really just the vehicle for a bigger experiment: what happens when I use AI to handle most of the implementation and spend my time deciding what to build, testing it, and figuring out what to do next?

Why I built this

I’ve got a CS degree, and my first job out of school was writing code — but coding itself was never the part I loved. What I love is building things and solving problems. For a long time, though, those two things pulled against each other: I’d have an idea, and the implementation would eat up all the time and patience I had for it. I’ve done LED strip projects before, without AI, and it took days to get something simple working — a lot of web searching, trying to bend someone else’s example to fit my wiring, running out of steam before it was actually done. Projects like that tend to die in a “works on my desk, nobody else could reproduce it” state.

What you’ll need

  • Raspberry Pi 5
  • SK6812 or WS2812B-compatible addressable LED strip
  • External 5V/6A power supply (mine is a YS-0506 adapter)
  • Half-size breadboard
  • Adafruit T-Cobbler Plus (optional — that’s what I used to break the GPIO header out to the breadboard; you can wire directly into the header instead)
  • Ribbon cable (if using the cobbler)
  • Jumper wires

Architecture & tech stack

Before I’d wired anything, I sent Claude photos of the strip and the power setup I was planning and described what I was trying to build. That’s how the blinking-on-power behavior got diagnosed as normal, before I second-guessed the wiring because of it.

Hardware: Raspberry Pi 5, SK6812/WS2812B-compatible addressable strip, an external 5V power supply (rather than powering the strip off the Pi’s own 5V rail — addressable strips can draw several amps at full brightness, more than the Pi’s onboard supply is really there for), a shared ground between the Pi and the strip’s power supply, and the data line on GPIO10/MOSI. My strip is 60 LEDs. At roughly 60 mA per pixel at full white, that’s an estimated 3.6 A worst case — an estimate, not a measurement — which is why the 6A supply, and why the code defaults brightness to 50% (get_strip(brightness=0.5)). The GPIO header isn’t wired point-to-point, though — it goes Pi → ribbon cable → an Adafruit T-Cobbler Plus mounted on a half-size breadboard, which breaks the 40 pins out to breadboard rows. The strip’s data and ground leads land on those rows instead of jumping straight into the Pi’s header, which made it a lot easier to move things around while I was still figuring out the wiring.

/images/led-strip-wiring-diagram.png

/images/led-strip-breadboard-cobbler.jpg

/images/led-strip-full-setup.jpg

Software: Python 3, the rpi5-ws2812 package, a bash launcher script, and a vanilla JS/HTML/CSS browser simulator with no build step.

The Pi 5 is the reason this couldn’t just follow any of the existing LED-strip tutorials. If you’ve found an older Raspberry Pi LED-strip tutorial, there’s an important catch: many of them point you at rpi_ws281x or Adafruit’s neopixel library, and neither works on a Pi 5. Previous Pi boards let those libraries talk straight to GPIO memory to hit the strip’s tight timing requirements; the Pi 5 moved GPIO handling to a separate chip called RP1, and that direct-register trick just doesn’t reach the pins anymore. It doesn’t error loudly — it just doesn’t work, which is worse.

I didn’t know any of that going in. Instead of hunting for the one tutorial written for a Pi 5, I described what I was trying to build and what I was actually seeing, and Claude researched the RP1/GPIO change and worked out the SPI-based fix.

The Pi 5 GPIO gotcha
If a Pi 5 GPIO tutorial predates the Pi 5 (most do), assume it needs a translation step. For this project, the fix was routing the LED data line over hardware SPI instead of PWM/DMA, using rpi5-ws2812 — the RP1 chip handles SPI natively.

Here’s how the pieces talk to each other:

graph LR run[run.sh] --> menu[led.py] menu -->|SIGINT, then wait| stop((stop current effect)) menu -->|subprocess.Popen| effect["effect.py<br/>(fire.py, pacman.py, ...)"] menu -->|key: o| off[off.py] effect --> common[led_common.py] off --> common common -->|SPI| strip[(LED strip)]

run.sh (symlinked as ledstrip on PATH) just erases the “was it source venv/bin/activate first or cd first?” friction — it creates the venv if one doesn’t exist yet and launches the menu.

Building the Project with Claude

To be explicit about it, since the rest of this post takes it for granted: I used Claude throughout the build — not just to help with a tricky function. I didn’t write the code, make the commits, or manage the repo. Instead, I focused on describing what I wanted to build, making design decisions, testing the results, identifying problems, and deciding what to tackle next. Claude handled the implementation — all eleven effects, led.py, led_common.py, off.py, and every git operation end-to-end.

Building without the hardware. At one point, I wanted an easier way to see what a new LED program would look like without having to plug in the Raspberry Pi and LED strip every time. If I was going to keep building new effects, I wanted a way to experiment with them virtually. I suggested building a visual validation tool that would let me preview effects without the physical hardware, and Claude took that idea and built the browser-based simulator. That changed how I worked: build and test an effect in the browser, then check the real strip only when I wanted the final answer.

How fast it was. The part that got me was the speed. I’d ask for something like a Pac-Man chase, and Claude had the code written in seconds. A few minutes later it was on GitHub and I was testing it on the strip.

Pac-Man chasing four ghosts down sixty LEDs is a ridiculous thing to want. It was also a very easy thing to ask for.

Eight years ago, when I first started messing with a Pi and an LED strip, I wrote everything myself. I had a couple of simple examples from the web, and not nearly the skills to build anything like it, pellets and the occasional cherry included. The first version took days to get working, and even after it worked, every change was hours of work.

Now a change was one prompt and a couple of seconds. That made the project more fun, not just faster. I got to spend my time watching the lights do different things and building on the idea.

Keeping a journal of the build. I didn’t want the project’s history trapped in one chat session or tied to one AI, so I had Claude maintain a JOURNEY.md in the repo as we went — decisions, changes in direction, problems, and lessons. That way I can pick the project up from any app, CLI, or AI. The longer version of this story lives there.

The barrier got pretty low. My 13-year-old has never written a line of code. He sent Claude a few chat requests of his own and ended up with the Rocket Launch effect in the menu.

Worth saying plainly: Claude didn’t hand me a wrong answer that I had to catch on this project. The Pi 5 diagnosis was right the first time, and the friction was architectural growing pains as the design evolved (more on that below). The real misses came later, on the LED-panel follow-up, which is a story for another post.

Key technical insights & challenges

The current structure — one file per effect, a persistent menu, off/quit as first-class options — is not how this started. Claude wrote all of it, but the design calls were still mine to make and push back on. It’s what we turned it into after hitting real friction, in order:

1. The monolith got painful fast. Everything started in one file — my call, and a mistake. Every new effect meant reopening the same file, which is how a quick script turns into a place you dread visiting. Rather than live with it, I had Claude pull shared setup into its own module and give every effect its own standalone file:

from rpi5_ws2812.ws2812 import WS2812SpiDriver

LED_COUNT = 60  # set to your strip's actual LED count

def get_strip(brightness: float = 0.5):
    strip = WS2812SpiDriver(spi_bus=0, spi_device=0, led_count=LED_COUNT).get_strip()
    strip.set_brightness(brightness)
    return strip

That’s the entirety of led_common.py. It exists because every effect needs the same two things — LED count and a configured strip object — and this is one place to change either, instead of eleven copies to keep in sync.

2. One effect at a time, without the menu blocking or the strip getting stuck. The project started with a single program you’d run and manually kill to try another. That pivoted to a menu that stays running and lets you pick a different effect while one’s already going — but early on, killing an effect could leave the strip frozen mid-pattern, a stray lit pixel glowing until the next run. I had Claude fix that in led.py by sending SIGINT to the running effect and waiting for it to exit before starting the next one, so each effect’s own cleanup gets a chance to run:

def stop_process(proc):
    if proc is not None and proc.poll() is None:
        proc.send_signal(signal.SIGINT)
        proc.wait()

Every effect wraps its animation loop the same way — except KeyboardInterrupt: strip.clear(); strip.show() — so led.py never needs to know anything effect-specific about how to turn one off. That “lights wouldn’t turn off” bug is also why off.py exists as a dedicated, synchronous one-shot action rather than just another backgrounded effect — turning the strip off shouldn’t itself be an animation you have to interrupt.

Past nine effects the menu also ran out of single-digit keys, so new effects pick up letters (a, b, c, …), with keys as explicit key-name-filename tuples in a PROGRAMS list rather than derived from list position — so adding a twelfth effect later can’t accidentally change what key 5 does.

Lessons learned & what’s next

If I started over, I’d probably go straight to the one-file-per-effect-plus-menu pattern instead of detouring through the monolith first — though I’m not sure I’d have known to, without hitting the pain of the monolith directly. The pivots came from testing on real hardware and running into real annoyances, not from planning it all upfront.

The bigger lesson is about where my attention went. Not having to write the Python myself meant I spent my time on the actual decisions — what should happen when the lights won’t turn off, when do we need letters instead of digits, is a browser simulator worth building. The typing was never the hard part. It was just the part that took all the time. Whatever your version of the LED strip is, the split is the same: figure out which parts of your project are the decisions and which are the typing, and be deliberate about handing off the typing.

This project was one experiment, and the JOURNEY.md approach has already evolved into something more structured in later projects.

Try it yourself

  1. Clone the repo.
  2. Connect the strip’s data line to GPIO10/MOSI.
  3. Connect the strip’s ground to the Pi’s ground.
  4. Power the strip from an external 5V supply.
  5. Enable SPI with sudo raspi-config (Interface Options → SPI).
  6. Run ./run.sh — it sets up the Python virtual environment the first time, then launches the menu.
git clone https://github.com/dwooods/led-strip.git
cd led-strip
./run.sh

/images/led-strip-menu-cli.png

No hardware handy? Open simulator.html in a browser — it’s a self-contained port of the same eleven effects, no server or build step required.

/images/led-strip-simulator.png

If you build on this, hit a bug, or want an effect that isn’t here yet, open an issue or send a PR. And if you want the longer, AI-written version of this story, JOURNEY.md is in the repo.

I wouldn’t have shared any of this if I’d had to do the coding myself. I’d have ended up with a script that worked on my desk and nowhere else. Instead there’s a repo, a simulator and this post, so someone else can build the same thing.