Skip to content

BLE Light Controller

Works with

BLE-capable boards — Adafruit Feather nRF52840 Express, Circuit Playground Bluefruit, CLUE

Libraries used: adafruit_ble · adafruit_led_animation · adafruit_bluefruit_connect


What you will build

A NeoPixel controller you run entirely from your phone. Open the free Bluefruit LE Connect app (iOS or Android), connect to your board, and use the Color Picker to set any color or the Control Pad to switch between animation effects — all over Bluetooth Low Energy. No USB cable, no WiFi, no server. Just two devices talking directly to each other.

The board runs a BLE UART service and listens for structured "packets" that the Bluefruit app sends. When it receives a color packet, the pixels change color. When it receives a button packet, the animation switches. The whole thing runs in a tight, responsive main loop.


Parts list

Part Notes
BLE-capable CircuitPython board Feather nRF52840, Circuit Playground Bluefruit, or CLUE
NeoPixel strip, ring, or strand 8–30 pixels, 5V data
5V power source USB power or LiPo + regulator for portable use
Level shifter (optional) Some strips need 5V data; most work fine from 3.3V
Bluefruit LE Connect app Free — iOS App Store or Google Play

Wiring

graph TD
    MCU["BLE Board\n(Feather nRF52840 / CPB / CLUE)"]
    NEO["NeoPixel Strip / Ring"]
    PWR["5V USB Power"]

    PWR -- "5V" --> NEO
    MCU -- "GND" --> NEO
    MCU -- "D5 (data)" --> NEO
    PWR -- "USB / VBUS" --> MCU

Tip

If you are using a Circuit Playground Bluefruit, the 10 onboard NeoPixels are already wired — use board.NEOPIXEL as the pixel pin and set NUM_PIXELS = 10. No external wiring needed.


Complete code

import board
import neopixel
from adafruit_ble import BLERadio
from adafruit_ble.advertising.standard import ProvideServicesAdvertisement
from adafruit_ble.services.nordic import UARTService
from adafruit_bluefruit_connect.packet import Packet
from adafruit_bluefruit_connect.color_packet import ColorPacket
from adafruit_bluefruit_connect.button_packet import ButtonPacket
from adafruit_led_animation.animation.solid import Solid
from adafruit_led_animation.animation.blink import Blink
from adafruit_led_animation.animation.chase import Chase
from adafruit_led_animation.animation.rainbow import Rainbow
from adafruit_led_animation.animation.rainbowchase import RainbowChase
from adafruit_led_animation.sequence import AnimationSequence
from adafruit_led_animation.color import RED, WHITE

# --- NeoPixel setup ---
PIXEL_PIN = board.D5
NUM_PIXELS = 8
pixels = neopixel.NeoPixel(PIXEL_PIN, NUM_PIXELS, brightness=0.3, auto_write=False)

# --- Animations ---
solid      = Solid(pixels, color=RED)
blink_anim = Blink(pixels, speed=0.5, color=RED)
chase      = Chase(pixels, speed=0.1, color=RED, size=3)
rainbow    = Rainbow(pixels, speed=0.1, period=2)
rainbow_ch = RainbowChase(pixels, speed=0.1)

ANIMATIONS = [solid, blink_anim, chase, rainbow, rainbow_ch]
current_anim_index = 0
current_color = RED

# --- BLE setup ---
ble = BLERadio()
uart_service = UARTService()
advertisement = ProvideServicesAdvertisement(uart_service)


def set_color(color):
    """Apply a new color to all color-aware animations."""
    global current_color
    current_color = color
    solid.color      = color
    blink_anim.color = color
    chase.color      = color


def current_animation():
    return ANIMATIONS[current_anim_index]


print("BLE Light Controller ready. Advertising...")

while True:
    # Advertise until a phone connects
    ble.start_advertising(advertisement)
    while not ble.connected:
        pass
    ble.stop_advertising()
    print("Connected!")

    while ble.connected:
        # Run one frame of the active animation
        current_animation().animate()

        # Check for incoming BLE packets (non-blocking)
        if uart_service.in_waiting:
            try:
                packet = Packet.from_stream(uart_service)
            except Exception:
                continue

            if isinstance(packet, ColorPacket):
                set_color(packet.color)
                print(f"Color: {packet.color}")

            elif isinstance(packet, ButtonPacket) and packet.pressed:
                if packet.button == ButtonPacket.BUTTON_1:
                    current_anim_index = 0   # Solid
                elif packet.button == ButtonPacket.BUTTON_2:
                    current_anim_index = 1   # Blink
                elif packet.button == ButtonPacket.BUTTON_3:
                    current_anim_index = 2   # Chase
                elif packet.button == ButtonPacket.BUTTON_4:
                    current_anim_index = 3   # Rainbow
                elif packet.button == ButtonPacket.RIGHT:
                    pixels.brightness = min(1.0, pixels.brightness + 0.1)
                elif packet.button == ButtonPacket.LEFT:
                    pixels.brightness = max(0.05, pixels.brightness - 0.1)
                print(f"Button: {packet.button}")

    print("Disconnected. Re-advertising...")
    pixels.fill((0, 0, 0))
    pixels.show()

How it works

The Bluefruit LE Connect protocol

Bluefruit LE Connect is Adafruit's free companion app that communicates over a Nordic UART BLE service — essentially a transparent serial pipe over Bluetooth. The app sends structured binary "packets" rather than raw text. Each packet starts with a ! byte and a type byte, followed by payload data and a checksum. The adafruit_bluefruit_connect library handles all the parsing: Packet.from_stream() reads bytes from the UART service, validates the checksum, and returns a typed packet object. You never have to parse raw bytes yourself.

ColorPacket and ButtonPacket

ColorPacket carries an RGB tuple — exactly the (r, g, b) format that NeoPixels and adafruit_led_animation expect. ButtonPacket carries a button identifier (1–4 or a direction) and a boolean for whether it was pressed or released. The app's Color Picker sends ColorPacket whenever you tap a color. The Control Pad sends ButtonPacket for each button press and release. By checking packet.pressed before acting on a ButtonPacket, you respond only to the press event and ignore the release — preventing double-triggers.

Running animations while polling BLE

The key design choice in the main loop is that current_animation().animate() is called on every iteration before checking for BLE input. The adafruit_led_animation animations are non-blocking — each call to .animate() advances the animation by one frame if enough time has passed (based on time.monotonic()), then returns immediately. This means the animation keeps running smoothly even while the board is waiting for input, and BLE input is checked on every loop iteration so latency stays low. The uart_service.in_waiting check avoids blocking on an empty receive buffer.


Remix ideas

Remix idea

Build a BLE MIDI controller. Swap the NeoPixel output for MIDI note messages and turn the phone buttons into instrument triggers. The BLE MIDI Controller hacker page walks through the MIDI BLE service.

Remix idea

Make a reactive wearable. Sew the board and a NeoPixel strip into a jacket. The colors respond to phone input while you wear it. The Reactive Wearable hacker page covers the wearable construction side.

Remix idea

Add a BLE keyboard shortcut layer. Use the same BLE UART service to also send keyboard HID events when specific buttons are held. The BLE Keyboard builder shows how to layer HID onto a BLE connection.


Go deeper