/// @file Chromancer.ino /// @brief Hexagonal LED display visualization /// @example Chromancer.ino /// /// This sketch is fully compatible with the FastLED web compiler. To use it do the following: /// 1. Install Fastled: `pip install fastled` /// 2. cd into this examples page. /// 3. Run the FastLED web compiler at root: `fastled` /// 4. When the compiler is done a web page will open. /* Original Source: https://github.com/ZackFreedman/Chromance GaryWoo's Video: https://www.youtube.com/watch?v=-nSCtxa2Kp0 GaryWoo's LedMap: https://gist.github.com/Garywoo/b6cd1ea90cb5e17cc60b01ae68a2b770 GaryWoo's presets: https://gist.github.com/Garywoo/82fa67c6e1f9529dc16a01dd97d05d58 Chromance wall hexagon source (emotion controlled w/ EmotiBit) Partially cribbed from the DotStar example I smooshed in the ESP32 BasicOTA sketch, too (C) Voidstar Lab 2021 */ #include "fl/sketch_macros.h" #include "fl/warn.h" #if !SKETCH_HAS_LOTS_OF_MEMORY // Platform does not have enough memory // Other platforms have weird issues. Will revisit this later. #include void setup() { // Use Serial.println instead of FL_WARN to prevent optimization away Serial.begin(115200); Serial.println("Chromancer.ino: setup() - Platform has insufficient memory for full demo"); } void loop() { // Use Serial.println instead of FL_WARN to prevent optimization away Serial.println("Chromancer.ino: loop() - Platform has insufficient memory for full demo"); delay(1000); // Prevent rapid printing } #else #include #include "fl/screenmap.h" #include "fl/math_macros.h" #include "fl/json.h" #include "fl/ui.h" #include "fl/map.h" #include "fl/str.h" #include "./screenmap.json.h" #include "./mapping.h" #include "./ripple.h" #include "./detail.h" using namespace fl; enum { BlackStrip = 0, GreenStrip = 1, RedStrip = 2, BlueStrip = 3, }; // Strips are different lengths because I am a dumb constexpr int lengths[] = { 154, // Black strip 168, // Green strip 84, // Red strip 154 // Blue strip }; // non emscripten uses separate arrays for each strip. Eventually emscripten // should support this as well but right now we don't CRGB leds0[lengths[BlackStrip]] = {}; CRGB leds1[lengths[GreenStrip]] = {}; CRGB leds2[lengths[RedStrip]] = {}; // Red CRGB leds3[lengths[BlueStrip]] = {}; CRGB *leds[] = {leds0, leds1, leds2, leds3}; byte ledColors[40][14][3]; // LED buffer - each ripple writes to this, then we // write this to the strips //float decay = 0.97; // Multiply all LED's by this amount each tick to create // fancy fading tails UISlider sliderDecay("decay", .97f, .8, 1.0, .01); // These ripples are endlessly reused so we don't need to do any memory // management #define numberOfRipples 30 Ripple ripples[numberOfRipples] = { Ripple(0), Ripple(1), Ripple(2), Ripple(3), Ripple(4), Ripple(5), Ripple(6), Ripple(7), Ripple(8), Ripple(9), Ripple(10), Ripple(11), Ripple(12), Ripple(13), Ripple(14), Ripple(15), Ripple(16), Ripple(17), Ripple(18), Ripple(19), Ripple(20), Ripple(21), Ripple(22), Ripple(23), Ripple(24), Ripple(25), Ripple(26), Ripple(27), Ripple(28), Ripple(29), }; // Biometric detection and interpretation // IR (heartbeat) is used to fire outward ripples float lastIrReading; // When our heart pumps, reflected IR drops sharply float highestIrReading; // These vars let us detect this drop unsigned long lastHeartbeat; // Track last heartbeat so we can detect noise/disconnections #define heartbeatLockout \ 500 // Heartbeats that happen within this many milliseconds are ignored #define heartbeatDelta 300 // Drop in reflected IR that constitutes a heartbeat // Heartbeat color ripples are proportional to skin temperature #define lowTemperature 33.0 // Resting temperature #define highTemperature 37.0 // Really fired up float lastKnownTemperature = (lowTemperature + highTemperature) / 2.0; // Carries skin temperature from temperature callback to IR callback // EDA code was too unreliable and was cut. // TODO: Rebuild EDA code // Gyroscope is used to reject data if you're moving too much #define gyroAlpha 0.9 // Exponential smoothing constant #define gyroThreshold \ 300 // Minimum angular velocity total (X+Y+Z) that disqualifies readings float gyroX, gyroY, gyroZ; // If you don't have an EmotiBit or don't feel like wearing it, that's OK // We'll fire automatic pulses #define randomPulsesEnabled true // Fire random rainbow pulses from random nodes #define cubePulsesEnabled true // Draw cubes at random nodes UICheckbox starburstPulsesEnabled("Starburst Pulses", true); UICheckbox simulatedBiometricsEnabled("Simulated Biometrics", true); #define autoPulseTimeout \ 5000 // If no heartbeat is received in this many ms, begin firing // random/simulated pulses #define randomPulseTime 2000 // Fire a random pulse every (this many) ms unsigned long lastRandomPulse; byte lastAutoPulseNode = 255; byte numberOfAutoPulseTypes = randomPulsesEnabled + cubePulsesEnabled + int(starburstPulsesEnabled); byte currentAutoPulseType = 255; #define autoPulseChangeTime 30000 unsigned long lastAutoPulseChange; #define simulatedHeartbeatBaseTime \ 600 // Fire a simulated heartbeat pulse after at least this many ms #define simulatedHeartbeatVariance \ 200 // Add random jitter to simulated heartbeat #define simulatedEdaBaseTime 1000 // Same, but for inward EDA pulses #define simulatedEdaVariance 10000 unsigned long nextSimulatedHeartbeat; unsigned long nextSimulatedEda; // Helper function to check if a node is on the border bool isNodeOnBorder(byte node) { for (int i = 0; i < numberOfBorderNodes; i++) { if (node == borderNodes[i]) { return true; } } return false; } UITitle title("Chromancer"); UIDescription description("Take 6 seconds to boot up. Chromancer is a wall-mounted hexagonal LED display that originally reacted to biometric data from an EmotiBit sensor. It visualizes your heartbeat, skin temperature, and movement in real-time. Chromancer also has a few built-in effects that can be triggered with the push of a button. Enjoy!"); UICheckbox allWhite("All White", false); UIButton simulatedHeartbeat("Simulated Heartbeat"); UIButton triggerStarburst("Trigger Starburst"); UIButton triggerRainbowCube("Rainbow Cube"); UIButton triggerBorderWave("Border Wave"); UIButton triggerSpiral("Spiral Wave"); bool wasHeartbeatClicked = false; bool wasStarburstClicked = false; bool wasRainbowCubeClicked = false; bool wasBorderWaveClicked = false; bool wasSpiralClicked = false; // Group related UI elements using UIGroup template multi-argument constructor UIGroup effectTriggers("Effect Triggers", simulatedHeartbeat, triggerStarburst, triggerRainbowCube, triggerBorderWave, triggerSpiral); UIGroup automationControls("Automation", starburstPulsesEnabled, simulatedBiometricsEnabled); UIGroup displayControls("Display", sliderDecay, allWhite); void setup() { Serial.begin(115200); Serial.println("*** LET'S GOOOOO ***"); Serial.println("JSON SCREENMAP"); Serial.println(JSON_SCREEN_MAP); fl::fl_map segmentMaps; ScreenMap::ParseJson(JSON_SCREEN_MAP, &segmentMaps); printf("Parsed %d segment maps\n", int(segmentMaps.size())); for (auto kv : segmentMaps) { Serial.print(kv.first.c_str()); Serial.print(" "); Serial.println(kv.second.getLength()); } // ScreenMap screenmaps[4]; ScreenMap red, black, green, blue; bool ok = true; auto red_it = segmentMaps.find("red_segment"); ok = (red_it != segmentMaps.end()) && ok; if (red_it != segmentMaps.end()) red = red_it->second; auto black_it = segmentMaps.find("back_segment"); ok = (black_it != segmentMaps.end()) && ok; if (black_it != segmentMaps.end()) black = black_it->second; auto green_it = segmentMaps.find("green_segment"); ok = (green_it != segmentMaps.end()) && ok; if (green_it != segmentMaps.end()) green = green_it->second; auto blue_it = segmentMaps.find("blue_segment"); ok = (blue_it != segmentMaps.end()) && ok; if (blue_it != segmentMaps.end()) blue = blue_it->second; if (!ok) { Serial.println("Failed to get all segment maps"); return; } CRGB* red_leds = leds[RedStrip]; CRGB* black_leds = leds[BlackStrip]; CRGB* green_leds = leds[GreenStrip]; CRGB* blue_leds = leds[BlueStrip]; FastLED.addLeds(black_leds, lengths[BlackStrip]).setScreenMap(black); FastLED.addLeds(green_leds, lengths[GreenStrip]).setScreenMap(green); FastLED.addLeds(red_leds, lengths[RedStrip]).setScreenMap(red); FastLED.addLeds(blue_leds, lengths[BlueStrip]).setScreenMap(blue); FastLED.show(); } void loop() { unsigned long benchmark = millis(); FL_UNUSED(benchmark); // Fade all dots to create trails for (int strip = 0; strip < 40; strip++) { for (int led = 0; led < 14; led++) { for (int i = 0; i < 3; i++) { ledColors[strip][led][i] *= sliderDecay.value(); } } } for (int i = 0; i < numberOfRipples; i++) { ripples[i].advance(ledColors); } for (int segment = 0; segment < 40; segment++) { for (int fromBottom = 0; fromBottom < 14; fromBottom++) { int strip = ledAssignments[segment][0]; int led = round(fmap(fromBottom, 0, 13, ledAssignments[segment][2], ledAssignments[segment][1])); leds[strip][led] = CRGB(ledColors[segment][fromBottom][0], ledColors[segment][fromBottom][1], ledColors[segment][fromBottom][2]); } } if (allWhite) { // for all strips for (int i = 0; i < 4; i++) { for (int j = 0; j < lengths[i]; j++) { leds[i][j] = CRGB::White; } } } FastLED.show(); // Check if buttons were clicked wasHeartbeatClicked = bool(simulatedHeartbeat); wasStarburstClicked = bool(triggerStarburst); wasRainbowCubeClicked = bool(triggerRainbowCube); wasBorderWaveClicked = bool(triggerBorderWave); wasSpiralClicked = bool(triggerSpiral); if (wasSpiralClicked) { // Trigger spiral wave effect from center unsigned int baseColor = random(0xFFFF); byte centerNode = 15; // Center node // Create 6 ripples in a spiral pattern for (int i = 0; i < 6; i++) { if (nodeConnections[centerNode][i] >= 0) { for (int j = 0; j < numberOfRipples; j++) { if (ripples[j].state == dead) { ripples[j].start( centerNode, i, Adafruit_DotStar_ColorHSV( baseColor + (0xFFFF / 6) * i, 255, 255), 0.3 + (i * 0.1), // Varying speeds creates spiral effect 2000, i % 2 ? alwaysTurnsLeft : alwaysTurnsRight); // Alternating turn directions break; } } } } lastHeartbeat = millis(); } if (wasBorderWaveClicked) { // Trigger immediate border wave effect unsigned int baseColor = random(0xFFFF); // Start ripples from each border node in sequence for (int i = 0; i < numberOfBorderNodes; i++) { byte node = borderNodes[i]; // Find an inward direction for (int dir = 0; dir < 6; dir++) { if (nodeConnections[node][dir] >= 0 && !isNodeOnBorder(nodeConnections[node][dir])) { for (int j = 0; j < numberOfRipples; j++) { if (ripples[j].state == dead) { ripples[j].start( node, dir, Adafruit_DotStar_ColorHSV( baseColor + (0xFFFF / numberOfBorderNodes) * i, 255, 255), .4, 2000, 0); break; } } break; } } } lastHeartbeat = millis(); } if (wasRainbowCubeClicked) { // Trigger immediate rainbow cube effect int node = cubeNodes[random(numberOfCubeNodes)]; unsigned int baseColor = random(0xFFFF); byte behavior = random(2) ? alwaysTurnsLeft : alwaysTurnsRight; for (int i = 0; i < 6; i++) { if (nodeConnections[node][i] >= 0) { for (int j = 0; j < numberOfRipples; j++) { if (ripples[j].state == dead) { ripples[j].start( node, i, Adafruit_DotStar_ColorHSV( baseColor + (0xFFFF / 6) * i, 255, 255), .5, 2000, behavior); break; } } } } lastHeartbeat = millis(); } if (wasStarburstClicked) { // Trigger immediate starburst effect unsigned int baseColor = random(0xFFFF); byte behavior = random(2) ? alwaysTurnsLeft : alwaysTurnsRight; for (int i = 0; i < 6; i++) { for (int j = 0; j < numberOfRipples; j++) { if (ripples[j].state == dead) { ripples[j].start( starburstNode, i, Adafruit_DotStar_ColorHSV( baseColor + (0xFFFF / 6) * i, 255, 255), .65, 1500, behavior); break; } } } lastHeartbeat = millis(); } if (wasHeartbeatClicked) { // Trigger immediate heartbeat effect for (int i = 0; i < 6; i++) { for (int j = 0; j < numberOfRipples; j++) { if (ripples[j].state == dead) { ripples[j].start(15, i, 0xEE1111, float(random(100)) / 100.0 * .1 + .4, 1000, 0); break; } } } lastHeartbeat = millis(); } if (millis() - lastHeartbeat >= autoPulseTimeout) { // When biometric data is unavailable, visualize at random if (numberOfAutoPulseTypes && millis() - lastRandomPulse >= randomPulseTime) { unsigned int baseColor = random(0xFFFF); if (currentAutoPulseType == 255 || (numberOfAutoPulseTypes > 1 && millis() - lastAutoPulseChange >= autoPulseChangeTime)) { byte possiblePulse = 255; while (true) { possiblePulse = random(3); if (possiblePulse == currentAutoPulseType) continue; switch (possiblePulse) { case 0: if (!randomPulsesEnabled) continue; break; case 1: if (!cubePulsesEnabled) continue; break; case 2: if (!starburstPulsesEnabled) continue; break; default: continue; } currentAutoPulseType = possiblePulse; lastAutoPulseChange = millis(); break; } } switch (currentAutoPulseType) { case 0: { int node = 0; bool foundStartingNode = false; while (!foundStartingNode) { node = random(25); foundStartingNode = true; for (int i = 0; i < numberOfBorderNodes; i++) { // Don't fire a pulse on one of the outer nodes - it // looks boring if (node == borderNodes[i]) foundStartingNode = false; } if (node == lastAutoPulseNode) foundStartingNode = false; } lastAutoPulseNode = node; for (int i = 0; i < 6; i++) { if (nodeConnections[node][i] >= 0) { for (int j = 0; j < numberOfRipples; j++) { if (ripples[j].state == dead) { ripples[j].start( node, i, // strip0.ColorHSV(baseColor // + (0xFFFF / 6) * i, // 255, 255), Adafruit_DotStar_ColorHSV(baseColor, 255, 255), float(random(100)) / 100.0 * .2 + .5, 3000, 1); break; } } } } break; } case 1: { int node = cubeNodes[random(numberOfCubeNodes)]; while (node == lastAutoPulseNode) node = cubeNodes[random(numberOfCubeNodes)]; lastAutoPulseNode = node; byte behavior = random(2) ? alwaysTurnsLeft : alwaysTurnsRight; for (int i = 0; i < 6; i++) { if (nodeConnections[node][i] >= 0) { for (int j = 0; j < numberOfRipples; j++) { if (ripples[j].state == dead) { ripples[j].start( node, i, // strip0.ColorHSV(baseColor // + (0xFFFF / 6) * i, // 255, 255), Adafruit_DotStar_ColorHSV(baseColor, 255, 255), .5, 2000, behavior); break; } } } } break; } case 2: { byte behavior = random(2) ? alwaysTurnsLeft : alwaysTurnsRight; lastAutoPulseNode = starburstNode; for (int i = 0; i < 6; i++) { for (int j = 0; j < numberOfRipples; j++) { if (ripples[j].state == dead) { ripples[j].start( starburstNode, i, Adafruit_DotStar_ColorHSV( baseColor + (0xFFFF / 6) * i, 255, 255), .65, 1500, behavior); break; } } } break; } default: break; } lastRandomPulse = millis(); } if (simulatedBiometricsEnabled) { // Simulated heartbeat if (millis() >= nextSimulatedHeartbeat) { for (int i = 0; i < 6; i++) { for (int j = 0; j < numberOfRipples; j++) { if (ripples[j].state == dead) { ripples[j].start( 15, i, 0xEE1111, float(random(100)) / 100.0 * .1 + .4, 1000, 0); break; } } } nextSimulatedHeartbeat = millis() + simulatedHeartbeatBaseTime + random(simulatedHeartbeatVariance); } // Simulated EDA ripples if (millis() >= nextSimulatedEda) { for (int i = 0; i < 10; i++) { for (int j = 0; j < numberOfRipples; j++) { if (ripples[j].state == dead) { byte targetNode = borderNodes[random(numberOfBorderNodes)]; byte direction = 255; while (direction == 255) { direction = random(6); if (nodeConnections[targetNode][direction] < 0) direction = 255; } ripples[j].start( targetNode, direction, 0x1111EE, float(random(100)) / 100.0 * .5 + 2, 300, 2); break; } } } nextSimulatedEda = millis() + simulatedEdaBaseTime + random(simulatedEdaVariance); } } } // Serial.print("Benchmark: "); // Serial.println(millis() - benchmark); } #endif // __AVR__