Introduction: Underglow LED Light
This project shows how to create an under-glow LED light for any moving vehicle e.g cars, RC cars, bikes, skateboards or as in my case, a scooter. It is based on using an MPU-6050 accelerometer sensor together with ATTINY85. I have tried to keep the design small, cheap and power efficient. The system changes LED color depending of the movement of the vehicle. I have selected green if driving faster (acceleration), red if breaking (deceleration) and blue if at constant speed or standing still. Color set and movement criteria can be changed by altering the code.
When not used it goes into standby after a period of time to save power and can therefore last in standby for several days before charging is needed.
Supplies
Parts
The design consists of 7 different parts:
- Neo-pixel, WS2812B 2x2pixel LED module
- ATTINY85-20PU
- MPU-6050, 3-axis Accelerometer and Gyro
- Battery Shield for Wemos D1
- 18650 Battery
- Veroboard and headers
- Encapsulation box(es)
Tools
- All tools related to soldering
- Welcro or other fitting method to in a solid way fit the box to the vehicle
- Glue-gun to make things stay in place the box
Design considerations
WS2812B 2x2pixel. System shall be self-contained and not hook into the vehicle electric system to avoid malfunction and unexpected power drain of vehicle. To avoid daily charging power consumption needs to be low. Since the largest contributor to power consumption is the LED light I use a 2x2 WS2812B which gives a good light effect and is not that very power hungry. One WS2812B pixel consumes 20mA per color (x3 if all colors are lit) so in this setup it consumes 4x20mA if using RGB colors separately. Also the price for this module is low, approx 3EUR.
ATTINY85 was selected since it fits the code needed, is inexpensive and despite only 6 I/Os it is sufficient in this case.
MPU-6050 was selected due to availability. This could be replaced by any accelerometer but obviously selecting another accelerometer will effect the code since I2C address and registers etc will be different for another sensor. MPU-6050 can be set for +/-2g, +/-4g, +/-8g, and +/-16g range. In this case +/-8g is used.
Battery shield, Wemos D1 is a good solution and comes in many cheap copies. Other battery shields may exist in a smaller form factor which might be needed if lack of space. Battery shield charges 3.7V cell with up to 1A and can deliver output 5V up to 1A. Cost is around 4EUR.
18650 Battery I went to the local battery repair firm and got some for free :-) I have a 2000mAh cell and that is more than sufficient. Note that a 18650 cell is larger than AA cells (18mm diameter 650mm length) something to consider when deciding on encapsulation. 18650 cell is around 7EUR.
Veroboard I use Veroboard and did not do a PCB design since this was a small project and easy to assemble. For ATTINY85, MPU-6050 and Battery Shield I use headers so modules can be moved in and out which is handy when doing programming (see Software & Programming section). Headers usually comes with the shields but I recommend also to mount it for to access the ATTINY85 pins so ISP can be connected easily.
Encapsulation box(es) for my design I needed a box that was 100x50x25mm. I had plastic boxes from Hammond but any brand will work. Also the Neopixel 2x2 needs encapsulation if it is located elsewhere than the rest of the electronics. I used a transparent plastic box that is 20x20x10mm. See pictures in Assembly section.
Step 1: Assembly
These are the pictures of the different steps of the assembly.
Additional notes
In the block schematics it shows a flatpack LiPo battery which is not correct, its has been replaced and I now use a 18650 battery.
The block schematics only shows 1x pixel LED. I use 2x2 but the wiring is identical.
There is a patch on the battery shield between pin2 on the TP5410 LiPo charge circuit and D1 (marked yellow). The reason for this is to route this signal to the ATTINY85 to indicate on Neopixel the charging status. This is very handy knowing when the charging is complete. It drove me crazy not knowing when charging was completed (I find it strange this is not standard on the shield when A0 analog battery level exist).
The system uses ATTINY85 sleep mode using watchdog (see Software & Programming section) this to reduce battery consumption. When LED is lit in one color it consumes >100mA@3V7. In standby now it uses < 10mA@3V7. With a battery of 2000mA the system can then theoretically be in standby for ~ 200h or more than 8 days.
Encapsulation has been tested throughout the winter and it is working good. No indication of water ingress.
Step 2: Software & Programming
In Arduino IDE for this project I needed to include libaries TinyWireM, Adafruit NeoPixel and SoftwareSerial. For Board Manager I used ATTinyCore for the ATTINY85. I used an Arduino UNO as ISP.
More about MPU-6050 can be found here
More about how to use ISP with ATTiny85 can be found here
ATTinyCore can be found here
Additional notes
When programming the ATTINY85 it is powered from the Arduino Uno via 5V. It is strongly suggested to remove the battery shield as long as the ISP is connected to avoid dual powering problems, ground issues, smoke etc. If communication problems occurs during programming, remove also the MPU-6050. This is the reason why having headers on the shields to insert and remove shields easily.
The code is commented where I find needed. But worth mentioning is that I use Z-axis accelerometer to detect movement and another setup may use another axis. This code is prepared for this but commented out. Adjust code depending on your accelerometer orientation to get it to work. See below.
//Note! This code is using Z axis to determine forward/reverse acceleration
//change code if you have different setup
//if using X as active
//acc1Bias=accX-accErrorX; //change vs calibrated values X
//acc2Bias=(abs(accY-accErrorY)+abs(accZ-accErrorZ))/2; //change vs calibrated values YZ
//if using Y as active
//acc1Bias=accY-accErrorY; //change vs calibrated values Y
//acc2Bias=(abs(accX-accErrorX)+abs(accZ-accErrorZ))/2; //change vs calibrated values XZ
//if using Z as active
acc1Bias=accZ-accErrorZ; //change vs calibrated values Z
acc2Bias=(abs(accX-accErrorX)+abs(accY-accErrorY))/2; //change vs calibrated values XY
The code has a a number of defines
//#define DEBUG //this is to get debug data
#define FORWARD_LED_ACTIVE 1 //0 if inactive, 1 if active
#define REVERSE_LED_ACTIVE 1 //0 if inactive, 1 if active
#define IDLE_LED_ACTIVE 1 //0 if inactive, 1 if active
DEBUG is to uncomment for serial printouts on ATTINY85 PB3 (pin 2). This is handy during development but consumes memory space. For serial data I use a FTDI232 adapter to connect and debug. XXXX_LED_ACTIVE is to set 0 or 1 if this movement should be lit the LED or not.
ATTINY85 has limited memory and therefore data type float is not used in this sketch.
When acceleration XYZ is less than 0.05g the system goes into standby. The sleep is using watchdog to wake up every 2 second to check if acceleration has changed (movement). No change => go to sleep again. The watchdog sleep time has been selected to 2 seconds this since sleep time effects system responsiveness and it can be perceived as slow when sleep time > 2 seconds. At least when using it on my scooter.
Code
/*
* Underglow LED project
* MD 2024 PerL13
* Created 16 Jun 2024
*
*/
/*
* AttinyCore
* ATiny85 (no bootloader)
* 8MHz internal
* CPU Frequency
* LTO enabled
* milliseconds enabled
* EEPROM retained
* BOD enabled 2.7V
*/
#ifdef __AVR__
#include <avr/power.h>
#include <avr/sleep.h>
#include <avr/wdt.h>
#endif
#include <TinyWireM.h>
#include <Adafruit_NeoPixel.h>
#include <SoftwareSerial.h>
#define NEO 4
#define TX 3
#define RX 5
#define CHARGE 1
//#define DEBUG //this is to get debug data
#define FORWARD_LED_ACTIVE 0 //0 if inactive, 1 if active
#define REVERSE_LED_ACTIVE 1 //0 if inactive, 1 if active
#define IDLE_LED_ACTIVE 0 //0 if inactive, 1 if active
//LED settings
#define NUMPIXELS 4 //No of neopixels used
#define BRIGHTNESS 255 //this is the brigtness level of the Neopixel LED, adjust between 1-255
//accelerometer limits, range is 8g => limit in g is ACC_LIMIT_XXX/4096
#define ACC_LIMIT_FORWARD 410 //410/4096 = 0.1g
#define ACC_LIMIT_REVERSE -410 //-410/4096 = -0.1g
#define ACC_LIMIT_CAL 1229 //1229/4096 = 0.3g
#define ACC_LIMIT_SHTDWN 205 //205/4096 = 0.05g
//Time settings in ms
#define TIME_LED_ON 1000 //time led is on when detecting forward/reverse acceleration (to avoid too much flickering)
#define TIME_SHTDWN 60000 //time before shtdwn occur
#define TIME_CAL 10000 //time to be out of calibration
//gyro specific
#define MPU_I2C_ADDR 0x68 //MPU6050 I2C address
Adafruit_NeoPixel pixels(NUMPIXELS,NEO,NEO_GRB+NEO_KHZ800);
#ifdef DEBUG
SoftwareSerial debug_serial(RX,TX);
#endif
//
//LED effect
//
void RGB_wheel(void)
{
for(int i=0;i<5;i++)
{
for(int k=0; k<NUMPIXELS; k++)
{
pixels.clear();
pixels.setPixelColor(k,pixels.Color(255,255,0));
pixels.show();
delay(20);
}
pixels.clear();
pixels.show();
}
}
//Sets RGB led
//r_led_val, g_led_val, b_led_val: value between 0 - 255, 255 is max light (and current consumption)
//idles: if false, led setting will be untouched TIME_LED_ON ms
void RGB_color(const int r_led_val,const int g_led_val,const int b_led_val,const bool idles)
{
static bool led_active=false;
static unsigned long idle_time_last=0;
if(led_active==false)
{
if(idles==false)
{
idle_time_last=millis();
led_active=true;
}
pixels.setPixelColor(0,pixels.Color(r_led_val,g_led_val,b_led_val));
pixels.setPixelColor(1,pixels.Color(r_led_val,g_led_val,b_led_val));
pixels.setPixelColor(2,pixels.Color(r_led_val,g_led_val,b_led_val));
pixels.setPixelColor(3,pixels.Color(r_led_val,g_led_val,b_led_val));
pixels.show();
}
if((millis()-idle_time_last)>TIME_LED_ON)
{
led_active=false;
}
}
//get x,y, z acceleration
//if cal is set => x,y,x is averaged over 128 samples
void get_acceleration(int *x,int *y,int *z,const bool cal)
{
int accX=0;
int accY=0;
int accZ=0;
long tmpAccErrX=0;
long tmpAccErrY=0;
long tmpAccErrZ=0;
if(cal)
{
int cnt=0;
while (cnt<128)
{
TinyWireM.beginTransmission(MPU_I2C_ADDR);
TinyWireM.write(0x3B);
TinyWireM.endTransmission(false);
TinyWireM.requestFrom(MPU_I2C_ADDR,6);
accX=(TinyWireM.read()<<8|TinyWireM.read());
accY=(TinyWireM.read()<<8|TinyWireM.read());
accZ=(TinyWireM.read()<<8|TinyWireM.read());
tmpAccErrX+=long(accX);
tmpAccErrY+=long(accY);
tmpAccErrZ+=long(accZ);
cnt++;
}
//divide by 128
*x=(tmpAccErrX>>7);
*y=(tmpAccErrY>>7);
*z=(tmpAccErrZ>>7);
}
else
{
TinyWireM.beginTransmission(MPU_I2C_ADDR);
TinyWireM.write(0x3B);
TinyWireM.endTransmission(false);
TinyWireM.requestFrom(MPU_I2C_ADDR,6);
*x=(TinyWireM.read()<<8|TinyWireM.read());
*y=(TinyWireM.read()<<8|TinyWireM.read());
*z=(TinyWireM.read()<<8|TinyWireM.read());
}
#ifdef DEBUG
debug_serial.print(F("c:"));
debug_serial.print(cal);
debug_serial.print(F("/"));
debug_serial.print(*x);
debug_serial.print(F("/"));
debug_serial.print(*y);
debug_serial.print(F("/"));
debug_serial.println(*z);
#endif
}
//setup
void setup()
{
#if defined(__AVR_ATtiny85__) && (F_CPU==16000000)
clock_prescale_set(clock_div_1);
#endif
#ifdef DEBUG
debug_serial.begin(9600);
debug_serial.println(F("Underglow 1.2"));
#endif
//this is pullup since the battery shield will pull low at charging
pinMode(CHARGE, INPUT_PULLUP);
pixels.begin();
pixels.setBrightness(BRIGHTNESS);
TinyWireM.begin(); //Initialize communication
TinyWireM.beginTransmission(MPU_I2C_ADDR); //Start communication with MPU6050
TinyWireM.write(0x6B); //Talk to the register 6B
TinyWireM.write(0x00); //Make reset - place a 0 into the 6B register
TinyWireM.endTransmission(true); //end the transmission
// Configure Accelerometer Sensitivity
TinyWireM.beginTransmission(MPU_I2C_ADDR);
TinyWireM.write(0x1C); //Talk to the ACCEL_CONFIG register (1C hex)
TinyWireM.write(0x10); //Set the register bits as 00010000 (+/- 8g full scale range)
TinyWireM.endTransmission(true);
setup_watchdog(7); //approximately 2 seconds sleep
}
//loop
void loop()
{
int accX=0;
int accY=0;
int accZ=0;
int chargeState=0;
long acc1Bias=0; //acc bias from active axis
long acc2Bias=0; //acc bias from the 2 non-active axises
long acc3Bias=0; //acc bias from all 3 axises
//timer values for calibration and shutdown
static unsigned long calTime=millis();
static unsigned long shtdwnTime=millis();
//acc values to keep calibration offset
static int accErrorX=0;
static int accErrorY=0;
static int accErrorZ=0;
//acc values to keep previous reading
static int accXprev=0;
static int accYprev=0;
static int accZprev=0;
//to handle led off when in standby
static bool led_on=true;
//I use in loop delay1&2 100ms => ~10Hz update, in between show charge state
delay(80); //delay1
chargeState=digitalRead(CHARGE);
//if charging, set led
if((chargeState==LOW)&&led_on)
{
RGB_color(255,0,255,true); // Purple, no idle
}
delay(20); //delay2
get_acceleration(&accX,&accY,&accZ,false); //get acc values, no calibration
//Note! This code is using Z axis to determine forward/reverse acceleration
//change code if you have different setup
//if using X as active
//acc1Bias=accX-accErrorX; //change vs calibrated values X
//acc2Bias=(abs(accY-accErrorY)+abs(accZ-accErrorZ))/2; //change vs calibrated values YZ
//if using Y as active
//acc1Bias=accY-accErrorY; //change vs calibrated values Y
//acc2Bias=(abs(accX-accErrorX)+abs(accZ-accErrorZ))/2; //change vs calibrated values XZ
//if using Z as active
acc1Bias=accZ-accErrorZ; //change vs calibrated values Z
acc2Bias=(abs(accX-accErrorX)+abs(accY-accErrorY))/2; //change vs calibrated values XY
//change vs previous values XYZ
acc3Bias=(abs(accX-accXprev)+abs(accY-accYprev)+abs(accZ-accZprev))/3;
accXprev=accX;
accYprev=accY;
accZprev=accZ;
if(((acc1Bias)>ACC_LIMIT_FORWARD)&&FORWARD_LED_ACTIVE&&led_on)
{
//acceleration
RGB_color(0,255,0,false); //Set LED green, no idle
}
else if(((acc1Bias)<ACC_LIMIT_REVERSE)&&REVERSE_LED_ACTIVE&&led_on)
{
//retardation
RGB_color(255,0,0,false); //Set LED red, no idle
}
else if(IDLE_LED_ACTIVE&&led_on)
{
//No accleration i.e. standing still or at constant speed
RGB_color(0,0,255,true); //Set LED blue, idle
}
else
{
RGB_color(0,0,0,true); //Set LEDs to 0 == OFF, idle
}
//check if we are active => refresh shtdwnTime time to prevent shutdown
if(acc3Bias>ACC_LIMIT_SHTDWN)
{
shtdwnTime=millis();
}
//check if we are still calibrated => refresh calTime to prevent calibration
if(acc2Bias<ACC_LIMIT_CAL)
{
calTime=millis();
}
//then check timers
if((millis()-calTime)>TIME_CAL)
{
//to long out of calibration=>do calibration
RGB_wheel();
get_acceleration(&accErrorX,&accErrorY,&accErrorZ,true); //true, do calibration
calTime=millis();
}
if((millis()-shtdwnTime)>TIME_SHTDWN)
{
//goto shutdown
#ifdef DEBUG
debug_serial.println("shutdown");
#endif
//clear led
pixels.clear();
pixels.show();
//turn off leds
led_on=false; //turn off led so when wakeup from sleep it is not lit
system_sleep();
}
else
{
led_on=true; //set led ON when movement and not in shutdown
}
#ifdef DEBUG
debug_serial.print(accX);
debug_serial.print(F("/"));
debug_serial.print(accY);
debug_serial.print(F("/"));
debug_serial.print(accZ);
debug_serial.print(F("/"));
debug_serial.print(F("/"));
debug_serial.print(accErrorX);
debug_serial.print(F("/"));
debug_serial.print(accErrorY);
debug_serial.print(F("/"));
debug_serial.print(accErrorZ);
debug_serial.print(F("/"));
debug_serial.print(F("/"));
debug_serial.print(acc2Bias);
debug_serial.print(F("/"));
debug_serial.print(acc3Bias);
debug_serial.print(F("/"));
debug_serial.print(F("/"));
debug_serial.print(millis()-shtdwnTime);
debug_serial.print(F("/"));
debug_serial.print(millis()-calTime);
debug_serial.print(F("/"));
debug_serial.print(F("/"));
debug_serial.println(chargeState);
#endif
}
//set system into the sleep state
//system wakes up when wtchdog is timed out
void system_sleep()
{
_SFR_BYTE(ADCSRA)&=~_BV(ADEN); // switch Analog to Digitalconverter OFF
set_sleep_mode(SLEEP_MODE_PWR_DOWN); // sleep mode is set here
sleep_enable();
sleep_mode(); // System sleeps here
sleep_disable(); // System continues execution here when watchdog timed out
//_SFR_BYTE(ADCSRA)|=_BV(ADEN); // switch Analog to Digitalconverter ON
}
// 0=16ms, 1=32ms,2=64ms,3=128ms,4=250ms,5=500ms
// 6=1 sec,7=2 sec, 8=4 sec, 9= 8sec
void setup_watchdog(int ii)
{
byte bb;
if(ii>9 )ii=9;
bb=ii&7;
if(ii>7)bb|=(1<<5);
bb|=(1<<WDCE);
MCUSR&=~(1<<WDRF);
// start timed sequence
WDTCR|=(1<<WDCE)|(1<<WDE);
// set new watchdog timeout value
WDTCR=bb;
WDTCR|=_BV(WDIE);
}
// Watchdog Interrupt Service / is executed when watchdog timed out
ISR(WDT_vect)
{
//do nothing
}
Step 3: Final Words
Future improvements
Instead of using sleep with watchdog in standby an interrupt can be used and is supported by MPU-6050. This will make the system be more responsive waking up from standby.
A minor detail is that on the MPU-6050 shield there is a LED that is lit when board has power. This could be removed to save additional power.
Disclaimer
Laws around under-glow lights varies between countries. You should check with the laws of your particular country to find out if they are legal before using them on public roads.
Enjoy!