Colour Card
fishychair / August 2023
The Colour Card is a credit card sized board with a 16×9 array of WS2816C LEDs. It also comes with onboard light and temperature/humidity sensors, all powered by an ESP32.
The WS2816C RGB LEDs can display over 281 trillion different colours per pixel (65536 steps per colour channel).
The vast majority of consumer devices can only show around 16 million (256 steps per R/G/B).
This might sound overkill but the difference is extremely noticeable at lower brightness levels.
- Powered by an ESP32 SoC
- Array is composed of WS2816C, 16-bit RGB LEDs.
- BH1721 16-bit ambient light sensor connected via I2C
- SHTC3 temperature + humidity sensor connected via I2C
- ESD and overcurrent protection (1.5A)
- 1× push button connected to GPIO 4
What can you do with this board?
The colour card is a device perfect for displaying data, patterns or art.
Since the board has WiFi and Bluetooth capabilities, you can pull data from the internet or smart home devices and control/display that data accordingly.
How do you tell it what to do?
You can tell the colour card what to do by coding it with the Arduino IDE. The code is very simple to start with and is extremely beginner friendly.
Getting Started
The dev board can be programmed using the Arduino IDE with the appropriate board library installed. Advanced users can opt to use Platform IO instead.
Step 1: Installing Arduino IDE
Install Arduino IDE from the link below. IDE 2.x has not been tested, I personally use IDE 1.8 legacy.
Arduino IDE downloadStep 2: Installing ESP32 package
Copy this text below:
https://espressif.github.io/arduino-esp32/package_esp32_index.json;
Start Arduino IDE and go to File > Preferences.
Paste it in the "Additional Boards Manager URL" text box as shown below.
In Tools > Board > Board Manager, search for "esp32" and install the one by Espressif Systems as shown below.
Now you can select the ESP32 Dev Module under the ESP Arduino category.
Step 3: Installing NeoPixelBus library
The NeoPixelBus library by Makuna is necessary to control the WS2816C LEDs.
Install here.Step 4: Uploading code
Now that we have everything set up, we can upload some code to verify everything is working.
Copy and paste the code into the IDE and click the upload button. The Arduino IDE will compile and upload the code.
Make sure you have selected the correct serial port via Tools > Port.
/* Example code for starting with with colour card
*
* Sends a test pixel to every LED.
* Written by fishchair 17/8/2023
*/
#include <NeoPixelBus.h>
const int pixelpin = 23; // pixel data pin is connected to GPIO23
const int pixelcount = 144; // 144 total LEDs on board
const Rgb48Color defined_colour(150, 100, 0); // define a colour
const Rgb48Color black(0); // define black
NeoPixelBus<NeoGrbWs2816Feature, NeoWs2816Method> strip(pixelcount, pixelpin);
void setup()
{
strip.Begin(); // initalise strip
}
void loop()
{
for (int i = 0; i < pixelcount; i++) { // create for loop
if (i == 0) { // if first pixel, wrap around and
strip.SetPixelColor(pixelcount - 1, black); // turn off last pixel
}
else { // otherwise if any other pixel
strip.SetPixelColor(i - 1, black); // turn off previous pixel
}
strip.SetPixelColor(i, defined_colour); // set current pixel colour
strip.Show(); // update strip
}
}
After a successful upload, you should see the an LED scanning across the board.
Example 2
This second example is much more complex and is the original code that comes on the board.
Automatic brightness adjustment functions are implemented.
/* Default colour card code
*
* Mode button toggles between mode 1 and 2.
* Mode 1 runs a wrap-around simulation of the Game of Life
* Mode 2 displays a twinkling Starry Night
*/
#include <NeoPixelBus.h>
#include <Wire.h>
const int BH1721_ADDR = 0x23;
long lastMillis = 0;
long loops = 0;
#define light_poll_delay 120 // must be > 120 for sensor to get reading correctly
#define ref_gol_dimming_ratio 20000
#define image_dimming_ratio 8000 // 0-65535. higher = brighter
uint16_t gol_dimming_ratio = 10000; // 0-65535. higher = brighter
uint16_t current_light_level = 0;
uint16_t scaled_light_level;
#define sparkling_speed_h 4 // high end of sparkle speed
#define sparkling_speed_l 1 // low end of sparkle speed
#define sparkling_range_h 170 // 0-255
#define sparkling_range_l 0 // currently does nothing
#define s_chance 20 // chance from 0-100% (higher is more likely to spawn sparkle)
#define min_brightness 100 // 30 is good too
const int width = 16;
const int height = 9;
const int arraySize = width * height;
int gol_array[height][width];
int gol_next_array[height][width];
int gol_age[arraySize];
int sparkle_dir[arraySize]; // -1 = still. 0-127 = up. 128-255 = down.
int sparkle_lvl[arraySize]; //(12288, 54528, 51200);
int wave_array[width];
unsigned long previous_light_reading = 0;
Rgb48Color old_gol_clr_array[arraySize];
Rgb48Color new_gol_clr_array[arraySize];
Rgb48Color white = (65535);
Rgb48Color black = (0);
const Rgb48Color gol_live_color(255 * 256, 80 * 256, 2 * 256);
const Rgb48Color gol_dead_color(0, 0, 0);
const Rgb48Color gol_old_color(0, 180 * 256, 205 * 256);
const Rgb48Color text_color(195, 22, 255);
unsigned long current_time = 0;
const unsigned long goldelay = 440; // ms between each GOL cycle
const unsigned aging_speed = 50; // how quickly cells in GOL age (1-255) higher is faster aging
const uint16_t pixelcount = 144; // # of LEDs in matrix
const uint16_t pixelpin = 23; // WS2816 data out pin
const uint16_t switchpin = 4; // pause button pin
const unsigned long debounceDelay = 500; // the debounce time; increase if the output flickers
const int mode_num = 2; // # of modes
bool setup_flag = 1; // 0 = already setup, 1 = need to setup
int active_mode = 1; // starting mode
unsigned long lastDebounceTime = 0; // the last time the output pin was toggled
unsigned long lastgoltime = 0; // the last time GOL finished a cycle
NeoGamma<NeoGammaTableMethod> colorGamma; // for any fade animations, best to correct gamma
NeoPixelBus<NeoGrbWs2816Feature, NeoWs2816Method> strip(pixelcount, pixelpin);
HtmlColor starry_night[] PROGMEM = {
0x243266, 0x72808d, 0x49556d, 0x24325e, 0x344175, 0x505e82, 0x47587c, 0x3a4a79, 0x2c3b80, 0x5e6975, 0x485674, 0x25337b, 0x455a8b, 0x73868c, 0x7f9386, 0x7b868c,
0x4b608a, 0x2c3d8b, 0x213476, 0x5a5c59, 0x445989, 0x384f93, 0x536981, 0x4d5b81, 0x445588, 0x596f89, 0x536a78, 0x495b8b, 0x5a7ab1, 0xc9ca62, 0xeed032, 0xb6bf8a,
0x708799, 0x5a6c96, 0x3f557e, 0x546779, 0x7994a9, 0x617886, 0x556b83, 0x5f7896, 0x6e86a6, 0x53699b, 0x4a5d85, 0x6d879c, 0x4968a0, 0x768a8b, 0x8c9874, 0x778a88,
0x6e8492, 0x7c9bb7, 0x58655a, 0x2d343e, 0x476486, 0x45658b, 0x6379a1, 0x5d7497, 0x587198, 0x647998, 0x4c638d, 0x4b6595, 0x44659a, 0x4f73b1, 0x6d91c2, 0x96abac,
0x566b83, 0x597b9e, 0x434739, 0x060f11, 0x577195, 0xb2beb0, 0x8397a2, 0x486590, 0x4b648c, 0x527190, 0x7895a7, 0x768ca4, 0x7b96a1, 0x9db6a2, 0x7c887a, 0x706c5e,
0x748687, 0x6c90b2, 0x2d3a4b, 0x0c0c05, 0x475361, 0xa4aea6, 0x82979c, 0x5b768c, 0x627c90, 0x546d82, 0x59677c, 0x607286, 0x617482, 0x515f7c, 0x2c3f6e, 0x2b3059,
0x4b5966, 0x48648f, 0x334048, 0x171509, 0x16190d, 0x222f39, 0x304568, 0x3b4d6a, 0x465773, 0x374974, 0x253764, 0x28385b, 0x2b3a5e, 0x344976, 0x5f7896, 0x637383,
0x272f3e, 0x1c2439, 0x1c2120, 0x20211c, 0x1e1d1a, 0x12140c, 0x171825, 0x272f3a, 0x39424b, 0x35414d, 0x243347, 0x28353d, 0x35424b, 0x3e4f57, 0x3f4c55, 0x3d494e,
0x393e38, 0x434c3b, 0x2d3229, 0x1b1f1f, 0x1c221e, 0x1e1f19, 0x1c1d17, 0x272b25, 0x31383b, 0x333133, 0x332d2a, 0x383d45, 0x343d4e, 0x2e3444, 0x293036, 0x353a3c
};
void init_bh1721() {
Wire.beginTransmission(BH1721_ADDR);
Wire.write(0x10);
Wire.endTransmission();
}
uint16_t poll_bh1721() {
Wire.requestFrom(BH1721_ADDR, 2);
uint16_t data = Wire.read() << 8;
data |= Wire.read();
return data;
}
uint16_t light_correct(uint16_t data) {
// data = constrain(data, 0, 1000);
if (data < 5) {
//data = map(data, 0, 5, 4, 5);
data = min_brightness;
}
else if (data >= 5 && data < 50) {
data = map(data, 0, 50, min_brightness, 7000);
}
else if (data >= 50 && data < 1000) {
data = map(data, 0, 1000, 7000, 20000);
}
else if (data >= 1000) {
data = map(data, 0, 65535, 20000, 65535);
}
return data;
}
uint16_t exp_function(uint16_t input) {
float scaled_input = input / 65565.0;
long double exponential_value = pow(scaled_input, 2.5);
int output = -round(exponential_value * 65565.0 - 65565);
return output;
}
float exp_flt_function(float input) {
float exponential_value = pow(input, 1.2);
return exponential_value;
}
void sparkle() {
Rgb48Color color;
float mapped_progress;
int j = random(0, arraySize);
if (random(0, 100) < s_chance && sparkle_dir[j] == -1) {
sparkle_dir[j] = map(random(0, 127), 0, 127, sparkling_speed_l, sparkling_speed_h); // assign a sparkling speed
}
for (int i = 0; i < arraySize; i++) { // assigning sparkles
if (sparkle_dir[i] != -1) { // if sparkle needs to be incremented
if (sparkle_dir[i] < 128) { // if sparkle is rising
sparkle_lvl[i] += sparkle_dir[i]; // increment it
if (sparkle_lvl[i] >= sparkling_range_h) { // if reached peak
sparkle_lvl[i] = sparkling_range_h; // set to peak
sparkle_dir[i] += 128; // change direction to be negative
}
}
else { // if sparkle is falling
sparkle_lvl[i] -= sparkle_dir[i] - 128;
if (sparkle_lvl[i] <= 0) { // if reached bottom
sparkle_lvl[i] = 0; // constrain to 0
sparkle_dir[i] = -1; // assign stationary
}
}
mapped_progress = map(sparkle_lvl[i], 0, 255, 0, 1000) / 1000.0;
color = Rgb48Color::LinearBlend(starry_night[i], white, mapped_progress);
color = color.Dim(scaled_light_level);
strip.SetPixelColor(i, colorGamma.Correct(color));
}
}
}
void setup_image() {
for (int i = 0; i < pixelcount; i++) {
Rgb48Color color(starry_night[i]);
color = color.Dim(scaled_light_level);
strip.SetPixelColor(i, colorGamma.Correct(color));
}
}
void game_of_life() {
// Calculate the next iteration of the Game of Life
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
// Count the number of live neighbors
int liveNeighbors = 0;
// Check all neighboring cells, including diagonal cells (toroidal wrap)
for (int i = -1; i <= 1; i++) {
for (int j = -1; j <= 1; j++) {
// Calculate the coordinates of the neighbor cell (toroidal wrap)
int nx = (x + j + width) % width;
int ny = (y + i + height) % height;
// Skip the current cell
if (i == 0 && j == 0) continue;
// Count live neighbors
if (gol_array[ny][nx] == 1) {
liveNeighbors++;
}
}
}
// Apply the rules of the Game of Life
if (gol_array[y][x] == 1) {
// Cell is alive
if (liveNeighbors < 2 || liveNeighbors > 3) {
// Cell dies due to underpopulation or overpopulation
gol_next_array[y][x] = 0;
} else {
// Cell survives to the next generation
gol_next_array[y][x] = 1;
}
} else {
// Cell is dead
if (liveNeighbors == 3) {
// Cell becomes alive due to reproduction
gol_next_array[y][x] = 1;
} else {
// Cell remains dead
gol_next_array[y][x] = 0;
}
}
}
}
// Update the gol_array with the next iteration
memcpy(gol_array, gol_next_array, sizeof(gol_array));
for (int i = 0; i < pixelcount; i++) {
old_gol_clr_array[i] = new_gol_clr_array[i]; // old array = new array's old colour
if (gol_array[i / width][i % width] == 0) { // if dead
new_gol_clr_array[i] = gol_dead_color; // dead colour (black)
gol_age[i] = 0; // reset live timer
}
else {
new_gol_clr_array[i] = gol_live_color; // alive colour
gol_age[i] += aging_speed; // survived another generation
if (gol_age[i] > 255 - aging_speed) {
gol_age[i] = 255 - aging_speed;
}
}
if ((i + 1) % 16 == 0) {
}
}
}
void game_of_life_fade() {
float mapped_progress = map((millis() - lastgoltime), 0, goldelay, 0, 1000) / 1000.0;
/*
*
*/for (int i = 0; i < pixelcount; i++) {
Rgb48Color color = Rgb48Color::LinearBlend(old_gol_clr_array[i], new_gol_clr_array[i], exp_flt_function(mapped_progress)); // my own exp gradient
//Rgb48Color color = Rgb48Color::LinearBlend(old_gol_clr_array[i], new_gol_clr_array[i], mapped_progress); // native function
color = Rgb48Color::LinearBlend(color, gol_old_color, float(map(gol_age[i], 0, 255, 0, 1000) / 1000.0));
color = color.Dim(scaled_light_level);
strip.SetPixelColor(i, colorGamma.Correct(color));
}
}
void buttonread() {
if (!digitalRead(switchpin) && (millis() - lastDebounceTime) > debounceDelay) {
lastDebounceTime = millis();
active_mode++;
setup_flag = 1;
if (active_mode > mode_num) {
active_mode = 1;
}
}
}
void fps_count() {
if (current_time - lastMillis > 1000) {
Serial.print("FPS:");
Serial.println(loops);
lastMillis = current_time;
loops = 0;
}
loops++;
}
void setup()
{
strip.Begin();
strip.Show();
Wire.begin();
init_bh1721(); // initialise BH1721 to begin autoresolution measurements (0x10)
Serial.begin(115200);
Serial.println();
pinMode(switchpin, INPUT_PULLUP);
// Initialize the gol_array
randomSeed(analogRead(25)); // noise on pin 25 to generate true randomness
for (int i = 0; i < height; i++) {
for (int j = 0; j < width; j++) {
gol_array[i][j] = random(0, 2);
}
}
// initialise arrays
for (int i = 0; i < arraySize; i++) {
sparkle_dir[i] = -1;
old_gol_clr_array[i] = gol_dead_color;
new_gol_clr_array[i] = gol_dead_color;
sparkle_lvl[i] = 0;
}
}
void loop()
{
current_time = millis();
// fps_count();
buttonread();
if ((current_time - previous_light_reading) > light_poll_delay) {
current_light_level = poll_bh1721(); // read BH1721
previous_light_reading = current_time;
}
scaled_light_level = light_correct(current_light_level);
switch (active_mode) {
case 1:
if ((current_time - lastgoltime) > goldelay) { // wait x seconds
lastgoltime = current_time; // if reached, generate
game_of_life(); // next iteration of GOL
}
game_of_life_fade(); // animation
break;
case 2:
setup_image();
sparkle();
break;
}
strip.Show();
}