A spinning persistance of vision (POV) toy for the holidays.
First of all
My buddy Rick did all the mechanical aspects of this build. I want to showcase the electronics and wireless control here, and spill a bit of the internal details ![]()
![]() |
|---|
| Spinny Ball internals |
That white rectangle is the battery (the one that won’t stay on unless there’s a big enough load). The green is my circuit board running an older FTDI-based microcontroller. Notice lights only on half the loop.
Deets
Briefly on the mechanics: this thing is a bit dangerous. It uses an up-cycled fan motor for rotation, so it gets going pretty fast. A variable frequency drive is used to bring the speed under control. Balance… is an issue.
As for the electronics, the LEDs are powered by a battery that spins with the LEDs to avoid having to run wires through a rotating joint. Controlling the LED patterns is done wirelessly via an Arduino + nRF24L01 radio. Unfortunately pictures and video don’t quite capture the true POV visuals. Here’s a quick snippet anyway, including a little wireless fog triggering ![]()
![]() |
|---|
| Spinny Ball triggering |
Full Code
Spinny Ball Receiver
The code running here is a slightly modified version of the code used for my sync’d LED costumes. It still features my embarassing need to run 6+leds all the time to keep the battery on
.
//Gravy Spinny Thing Receiver
/*v1.0 ******Changed NUM_LEDS from 50 to 144****/
/********Copied From Receiver v1.2*******
* v1.1 - instituted power compensation to keep small batteries from auto-shutoff. With nothing connected it
* seems like there need to be more than 6 LEDs lit, full white. That number depends on what other LEDs are
* connected, even if not in use. 10 lit seems safe, haven't measured lifespan of battery yet...
* v1.2 - updated patterns, final version for Sitka!!
*/
#include <SPI.h>
#include "nRF24L01.h"
#include "RF24.h"
#include "printf.h"
#include "FastLED.h"
#define FASTLED_INTERRUPT_RETRY_COUNT 5
#define DATA_PIN 3
#define NUM_LEDS 50
#define BRIGHTNESS 100
#define LED_TYPE WS2811
#define COLOR_ORDER GRB
#define UPDATES_PER_SECOND 100
//PowerComp
#define POWERCOMP_DATA_PIN 4
#define POWERCOMP_NUM_LEDS 10 //seems to need more than 6 LEDs hooked up when 10 are connected.
//Went with 10 lit up, seemed safe. It's possible that with more LEDs onnected to the system,
//less than 10 lit would work... possibly down to just a few.
CRGB powercomp_leds[POWERCOMP_NUM_LEDS];
CRGB leds[NUM_LEDS];
RF24 radio(9,10); //Set up radio
const uint8_t led_pins[] = {3};
const uint8_t num_led_pins = sizeof(led_pins);
const uint64_t pipe = 0xE8E8F0F0E1LL;
const uint8_t button_pins[] = { 3,4,5,6 };
const uint8_t num_button_pins = sizeof(button_pins);
uint8_t button_states[num_button_pins];
uint8_t led_states[num_led_pins];
int sceneToRun = 0;
int activeScene = 0;
volatile uint8_t gHue = 0;
uint8_t gHueMaster = 0;
volatile uint8_t lastButtonPress;
static boolean blinkPulseState = false;
unsigned long previousMillis = 0;
const long interval = 100;
//Moving Gradient Specific
CHSV gradStartColor(1,255,255); // Gradient start color. 275, 100, 100 232, 100, 100 1, 255, 255 105,255,255
CHSV gradEndColor(105,255,255); // Gradient end color.
uint8_t gradStartPos = 0;// Starting position of the gradient.
#define gradLength 50 // How many pixels (in total) is the grad from start to end.
int8_t gradDelta = 1; // 1 or -1. (Negative value reverses direction.)
// If you wanted to move your gradient 32 pixels in 120 seconds, then:
// 120sec / 32pixel = 3.75sec
// 3.75sec x 1000miliseconds/sec = 3750milliseconds
#define gradMoveDelay 50 // How fast to move the gradient (in Milliseconds)
CRGB grad[gradLength]; // A place to save the gradient colors. (Don't edit this)
//End Moving Gradient Specific
//Blood Specific
uint8_t bloodHue = 54; // Blood color [hue from 0-255]
uint8_t bloodHueMaster = 54;
uint8_t bloodSat = 255; // Blood staturation [0-255]
int flowDirection = -1; // Use either 1 or -1 to set flow direction
uint16_t cycleLength = 1100; // Lover values = continuous flow, higher values = distinct pulses.
uint16_t pulseLength = 350; // How long the pulse takes to fade out. Higher value is longer.
uint16_t pulseOffset = 300; // Delay before second pulse. Higher value is more delay.
uint8_t baseBrightness = 10; // Brightness of LEDs when not pulsing. Set to 0 for off.
void setup() {
Serial.begin(115200);
printf_begin();
printf("\n\rGravy Receiver\n\r");
printf("LED Pin: %d\n\r",led_pins[0]);
radio.begin();
radio.openReadingPipe(1,pipe);
radio.startListening();
radio.printDetails();
FastLED.addLeds<NEOPIXEL, DATA_PIN>(leds, NUM_LEDS).setCorrection(TypicalLEDStrip);
FastLED.addLeds<NEOPIXEL, POWERCOMP_DATA_PIN>(powercomp_leds, POWERCOMP_NUM_LEDS).setCorrection(TypicalLEDStrip);
FastLED.setBrightness(BRIGHTNESS);
fill_gradient(leds, gradStartPos, gradStartColor, gradStartPos+gradLength-1, gradEndColor, SHORTEST_HUES); //Moving Gradient Specific
fill_solid(powercomp_leds, POWERCOMP_NUM_LEDS, CRGB::White);
FastLED.show();
activeScene = 3;
}
//Utility
void showStrip() {
#ifdef ADAFRUIT_NEOPIXEL_H
strip.show(); // NeoPixel
#endif
#ifndef ADAFRUIT_NEOPIXEL_H
FastLED.show(); // FastLED
#endif
}
void setPixel(int Pixel, byte red, byte green, byte blue) {
#ifdef ADAFRUIT_NEOPIXEL_H
strip.setPixelColor(Pixel, strip.Color(red, green, blue)); // NeoPixel
#endif
#ifndef ADAFRUIT_NEOPIXEL_H
leds[Pixel].r = red; // FastLED
leds[Pixel].g = green;
leds[Pixel].b = blue;
#endif
}
void setAll(byte red, byte green, byte blue) {
for(int i = 0; i < NUM_LEDS; i++ ) {
setPixel(i, red, green, blue);
}
showStrip();
}
//patterns
void blinkPulse()
{
unsigned long currentMillis = millis();
if (currentMillis - previousMillis >= interval) { // save the last time you blinked the LED
previousMillis = currentMillis;
LEDS.setBrightness(100);
fill_solid(leds, NUM_LEDS, CRGB::White);
FastLED.show();
}
/* if the LED is off turn it on and vice-versa:
if (ledState == LOW) {
ledState = HIGH;
} else {
ledState = LOW;
}*/
else
fill_solid(leds, NUM_LEDS, CRGB::Black);
FastLED.show();
/*EVERY_N_MILLISECONDS(1000) {
printf("entered time loop/n/r");
if (blinkPulseState = true) {
printf("blinkPulseState was true, now is false \n\r");
LEDS.setBrightness(100);
fill_solid(leds, NUM_LEDS, CRGB::White);
FastLED.show();
}
else if (blinkPulseState = false) {
printf("blinkPulseState was false, now is true \n\r");
fill_solid(leds, NUM_LEDS, CRGB::Black);
FastLED.show();
}
blinkPulseState = !blinkPulseState;
}*/
/*
gHue = gHue+1;
LEDS.setBrightness(100);
fill_solid(leds, NUM_LEDS, CRGB::White);
FastLED.show();
}
LEDS.setBrightness(100);
fill_solid(leds, NUM_LEDS, CRGB::White);
FastLED.show();
delay(30);
fill_solid(leds, NUM_LEDS, CRGB::Black);
FastLED.show();*/
//activeScene=0;
}
void confetti()
{
// random colored speckles that blink in and fade smoothly
fadeToBlackBy( leds, NUM_LEDS, 10);
int pos = random16(NUM_LEDS);
leds[pos] += CHSV( gHue + random8(64), 200, 255);
FastLED.show();
EVERY_N_MILLISECONDS(gradMoveDelay*10) {
gHue = gHue+1;
}
}
void sparkle(byte red, byte green, byte blue, int SpeedDelay)
{
int Pixel = random(NUM_LEDS);
setPixel(Pixel,red,green,blue);
showStrip();
delay(SpeedDelay);
setPixel(Pixel,0,0,0);
}
void twinkle(byte red, byte green, byte blue, int Count, int SpeedDelay, boolean OnlyOne)
{
LEDS.setBrightness(100);
fill_solid(leds, NUM_LEDS, CRGB::White);
FastLED.show();
//setAll(0,0,0);
//for (int i=0; i<Count; i++) {
// setPixel(random(NUM_LEDS),red,green,blue);
//showStrip();
//delay(SpeedDelay);
//if(OnlyOne) {
//setAll(0,0,0);
//}
//}
//delay(SpeedDelay);
LEDS.setBrightness(BRIGHTNESS);
}
void soundReactive()
{
LEDS.setBrightness(100);
fill_solid(leds, NUM_LEDS, CRGB::White);
FastLED.show();
delay(60);
fill_solid(leds, NUM_LEDS, CRGB::Black);
FastLED.show();
activeScene=0;
LEDS.setBrightness(BRIGHTNESS);
}
void whiteLowPower() {
//LEDS.setBrightness();
fill_solid(leds, NUM_LEDS, CRGB::Black);
FastLED.show();
LEDS.setBrightness(BRIGHTNESS);
}
void randColorMovement() {
fill_solid(leds, NUM_LEDS, CRGB::Blue);
LEDS.setBrightness(100);
FastLED.show();
LEDS.setBrightness(BRIGHTNESS);
}
void rainbowRandomGlitter() {
uint8_t beatA = beatsin8(35, 0, 255); // Starting hue
//uint8_t beatB = beatsin8(13, 0, 255);
fill_rainbow(leds, NUM_LEDS, beatA, 5);
//rainbow(); // Built-in FastLED rainbow, plus some random sparkly glitter.
//addGlitter(50);
FastLED.show();
}
void rainbow()
{
fill_rainbow( leds, NUM_LEDS, gHue, 7); // FastLED's built-in rainbow generator
EVERY_N_MILLISECONDS(gradMoveDelay) {
gHue = gHue+10;
}
}
void addGlitter(fract8 chanceOfGlitter) {
if(random8() < chanceOfGlitter) {
leds[ random16(NUM_LEDS) ] += CRGB::White;
}
}
void addColorGlitter(fract8 chanceOfGlitter) {
if(random8() < chanceOfGlitter) {
leds[ random16(NUM_LEDS) ] += CHSV( gHue + random8(64), 200, 255);
delay(15);
}
}
void finale() {
LEDS.setBrightness(90);
fill_solid(leds, NUM_LEDS, CRGB::White);
addColorGlitter(90);
FastLED.show();
/* int DISPLAYTIME = 15;
uint8_t secs = (millis() / 1000) % (DISPLAYTIME * 2);
if(secs<DISPLAYTIME) {
LEDS.setBrightness(5);
fill_solid(leds, NUM_LEDS, CRGB::White);
FastLED.show();
}
else {
LEDS.setBrightness(100);
fill_solid(leds, NUM_LEDS, CRGB::White);
FastLED.show();
}
LEDS.setBrightness(BRIGHTNESS);*/
}
void redAlert()
{
LEDS.setBrightness(100);
fill_solid(leds, NUM_LEDS,CRGB::Red);
addGlitter(80);
FastLED.show();
}
/*{
int DISPLAYTIME = 1;
uint8_t secs = (millis() / 1000) % (DISPLAYTIME * 2);
if(secs<DISPLAYTIME) {
LEDS.setBrightness(100);
fill_solid(leds, NUM_LEDS, CRGB::Red);
FastLED.show();
}
else {
LEDS.setBrightness(1);
fill_solid(leds, NUM_LEDS, CRGB::Red);
FastLED.show();
}
LEDS.setBrightness(BRIGHTNESS);
}*/
void scan() {
fadeToBlackBy( leds, NUM_LEDS, 20);
int pos = beatsin16(13,0,NUM_LEDS);
leds[pos] += CHSV( gHue, 255, 192);
FastLED.show();
EVERY_N_MILLISECONDS(gradMoveDelay) {
gHue = gHue+1;
}
}
void blur() {
uint8_t blurAmount = dim8_raw( beatsin8(50,64, 192) ); // A sinewave at 3 Hz with values ranging from 64 to 192.
blur1d( leds, NUM_LEDS, blurAmount); // Apply some blurring to whatever's already on the strip, which will eventually go black.
uint8_t i = beatsin8( 7, 0, NUM_LEDS);
uint8_t j = beatsin8( 5, 0, NUM_LEDS);
uint8_t k = beatsin8( 3, 0, NUM_LEDS);
// The color of each point shifts over time, each at a different speed.
uint16_t ms = millis()+1;
leds[(i+j)/2] = CHSV( ms / 40, 200, 255);
leds[(j+k)/2] = CHSV( ms / 45, 200, 255);
leds[(k+i)/2] = CHSV( ms / 50, 200, 255);
leds[(k+i+j)/3] = CHSV( ms / 55, 200, 255);
FastLED.show();
}
void moveGradient() {
EVERY_N_MILLISECONDS(gradMoveDelay) {
uint8_t count = 0;
fill_gradient (leds, gradStartPos, gradStartColor, gradLength, gradEndColor, SHORTEST_HUES);
/* for (uint8_t i = gradStartPos; i < gradStartPos+gradLength; i++) {
leds[i % NUM_LEDS] = leds[count];
count++;
}
*/
FastLED.show(); // Display the pixels.
FastLED.clear(); // Clear the strip to not leave behind lit pixels as grad moves.
/*gradStartPos = gradStartPos + gradDelta; // Update start position.
if ( (gradStartPos > NUM_LEDS-1) || (gradStartPos < 0) ) { // Check if outside NUM_LEDS range
gradStartPos = gradStartPos % NUM_LEDS; // Loop around as needed
}*/
}
}
void heartBeat(){
for (int i = 0; i < NUM_LEDS ; i++) {
uint8_t bloodVal = sumPulse( (5/NUM_LEDS/2) + (NUM_LEDS/2) * i * flowDirection );
leds[i] = CHSV( bloodHue, bloodSat, bloodVal );
}
FastLED.show();
}
int sumPulse(int time_shift) {
EVERY_N_MILLISECONDS(3000) {
bloodHue = bloodHue+5;
uint8_t prev_time_shift = time_shift;
printf("entered every 'n' milliseconds\n\r");
if(prev_time_shift < 1){
time_shift = (5/NUM_LEDS/2) + (NUM_LEDS/2) * flowDirection;
printf("time shift = something\n\r");
}
else time_shift = 0;
printf("time shift = 0\n\r");
}
//time_shift = 0; //Uncomment to heart beat/pulse all LEDs together
int pulse1 = pulseWave8( millis() + time_shift, cycleLength, pulseLength );
int pulse2 = pulseWave8( millis() + time_shift + pulseOffset, cycleLength, pulseLength );
return qadd8( pulse1, pulse2 ); // Add pulses together without overflow
}
uint8_t pulseWave8(uint32_t ms, uint16_t cycleLength, uint16_t pulseLength) {
uint16_t T = ms % cycleLength;
if ( T > pulseLength) return baseBrightness;
uint16_t halfPulse = pulseLength / 2;
if (T <= halfPulse ) {
return (T * 255) / halfPulse; //first half = going up
} else {
return((pulseLength - T) * 255) / halfPulse; //second half = going down
}
}
void solidFill() {
fill_solid( leds, NUM_LEDS, CHSV(gHue,255,255));
FastLED.show();
EVERY_N_MILLISECONDS(100) {
gHue+=1;
}
}
//Main loop
void loop() {
if ( radio.available() ) {// if there is data ready
while (radio.available()) {// Dump the payloads until we've gotten everything
radio.read( &sceneToRun, num_button_pins );// Fetch the payload, and see if this was the last one.
printf("Got buttons\n\r"); // Spew it
activeScene = sceneToRun;
printf("Received: %d\n\r", activeScene);
lastButtonPress = millis();
gHue = gHueMaster;
bloodHue = bloodHueMaster;
}
}
else {
// run case/switch to determine which loop to run
switch(activeScene) {
case 1: solidFill();
break;
case 2: blinkPulse();
break;
case 3: rainbowRandomGlitter();
break;
case 4: finale();
break;
case 5: confetti();
break;
case 6: sparkle(0xff, 0xff, 0xff, 0);
break;
case 7: heartBeat();
break;
case 8: redAlert();//sparkle(0xff,0,0,0);
break;
case 9: whiteLowPower();
break;
case 10: solidFill();
break;
/*
case 1: blinkPulse();
break;
case 2: confetti();
break;
case 3: sparkle(0xff, 0xff, 0xff, 0);
break;
case 4: twinkle(0xff, 0, 0, 10, 100, false);
break;*/
}
}
}
Relay Receiver
//Gravy Relay Receiver
/*v1.0 Created to plug into wired switch on fog machine and relay a button press to turn on a fog burst****/
/********Copied From Receiver v1.2*******
* [legacy]v1.1 - instituted power compensation to keep small batteries from auto-shutoff. With nothing connected it
* seems like there need to be more than 6 LEDs lit, full white. That number depends on what other LEDs are
* connected, even if not in use. 10 lit seems safe, haven't measured lifespan of battery yet...
* [legacy]v1.2 - updated patterns, final version for Sitka!!
*/
#include <SPI.h>
#include "nRF24L01.h"
#include "RF24.h"
#include "printf.h"
#include "FastLED.h"
#define RELAY1 3
//PowerComp
#define BRIGHTNESS 100
#define LED_TYPE WS2811
#define COLOR_ORDER GRB
#define POWERCOMP_DATA_PIN 5
#define POWERCOMP_NUM_LEDS 10 //seems to need more than 6 LEDs hooked up when 10 are connected.
//Went with 10 lit up, seemed safe. It's possible that with more LEDs onnected to the system,
//less than 10 lit would work... possibly down to just a few.
CRGB powercomp_leds[POWERCOMP_NUM_LEDS];
RF24 radio(9,10); //Set up radio
const uint64_t pipe = 0xE8E8F0F0E1LL;
int sceneToRun = 0;
int activeScene = 0;
const uint8_t num_button_pins = 9;
volatile long lastButtonPress = 0;
volatile long lastFog = 0;
long interval = 2000;
void setup()
{
pinMode(RELAY1, OUTPUT);
Serial.begin(115200);
printf_begin();
printf("\n\rGravy Relay Receiver\n\r");
radio.begin();
radio.openReadingPipe(1,pipe);
radio.startListening();
radio.printDetails();
//PowerComp
FastLED.addLeds<NEOPIXEL, POWERCOMP_DATA_PIN>(powercomp_leds, POWERCOMP_NUM_LEDS).setCorrection(TypicalLEDStrip);
FastLED.setBrightness(BRIGHTNESS);
fill_solid(powercomp_leds, POWERCOMP_NUM_LEDS, CRGB::White);
FastLED.show();
}
void fogOn(){
digitalWrite(RELAY1,LOW); // Turns ON Relays 1
lastFog = millis();
printf("fogOn ran \n\r");
activeScene = 2;
}
void fogOff(){
digitalWrite(RELAY1,HIGH); // Turns Relay Off
printf("fogOff ran \n\r");
}
void loop()
{
unsigned long currentMillis = millis();
EVERY_N_MILLISECONDS(3000) {
if ( currentMillis - lastFog > interval) {
fogOff();
}
}
if ( radio.available() ) {// if there is data ready
while (radio.available()) {// Dump the payloads until we've gotten everything
radio.read( &sceneToRun, num_button_pins );// Fetch the payload, and see if this was the last one.
printf("Got buttons\n\r"); // Spew it
activeScene = sceneToRun;
printf("Received: %d\n\r", activeScene);
lastButtonPress = millis();
}
}
else {
// run case/switch to determine which loop to run
switch(activeScene) {
case 1:
break;
case 2:
break;
case 3:
break;
case 4:
break;
case 5:
break;
case 6:
break;
case 7:
break;
case 8:
break;
case 9:
break;
case 10: fogOn();
}
}
}
For details on the footswitch, see the Costumes write-up.

