#define RENDERBUG_VERSION 1 #define PLATFORM_PHOTON //#define PLATFORM_ESP2800 #include "FastLED/FastLED.h" #include "Figments/Figments.h" #include "Static.h" #include "Config.h" #include "colors.h" #include "WebTelemetry.cpp" #include "MDNSService.cpp" #include "Sequencer.h" #include "animations/Power.cpp" #include "animations/SolidAnimation.cpp" #include "animations/Chimes.cpp" #include "animations/Flashlight.cpp" #include "animations/Drain.cpp" #include "animations/UpdateStatus.h" #include "inputs/ColorCycle.h" #include "inputs/Buttons.h" #include "inputs/MPU6050.h" #ifdef PLATFORM_PHOTON #include "platform/particle/inputs/Photon.h" #include "platform/particle/inputs/CloudStatus.h" #include "platform/particle/PhotonTelemetry.h" #endif SerialLogHandler logHandler; using namespace NSFastLED; #define MAX_BRIGHTNESS 255 //#define PSU_MILLIAMPS 4800 //#define PSU_MILLIAMPS 500 //#define PSU_MILLIAMPS 1000 #define PSU_MILLIAMPS 2000 // Enable system thread, so rendering happens while booting SYSTEM_THREAD(ENABLED); // Setup FastLED and the display CRGB leds[HardwareConfig::MAX_LED_NUM]; Display dpy(leds, HardwareConfig::MAX_LED_NUM, Static::instance()->coordMap()); LinearCoordinateMapping neckMap{60, 0}; Display neckDisplay(leds, HardwareConfig::MAX_LED_NUM, &neckMap); // Setup power management Power power; // Clip the display at whatever is configured while still showing over-paints FigmentFunc displayClip([](Display* dpy) { auto coords = Static::instance()->coordMap(); for(int i = 0; i < HardwareConfig::MAX_LED_NUM; i++) { if (i < coords->startPixel || i > coords->pixelCount + coords->startPixel) { dpy->pixelAt(i) %= 40; } } }); FigmentFunc configDisplay([](Display* dpy) { uint8_t brightness = brighten8_video(beatsin8(60)); auto coords = Static::instance()->coordMap(); for(int i = 0; i < HardwareConfig::MAX_LED_NUM; i++) { if (i < coords->startPixel || i > coords->startPixel + coords->pixelCount) { dpy->pixelAt(i) += CRGB(brightness, 0, 0); } else { dpy->pixelAt(i) += CRGB(255 - brightness, 255 - brightness, 255 - brightness); } } }); class InputBlip: public Figment { public: InputBlip() : Figment("InputBlip", Task::Stopped) {} void handleEvent(const InputEvent& evt) override { if (evt.intent != InputEvent::None) { m_time = qadd8(m_time, 5); } } void loop() override { if (m_time > 0) { m_time--; } } void render(Display* dpy) const override { if (m_time > 0) { dpy->pixelAt(0) = CRGB(0, brighten8_video(ease8InOutApprox(m_time)), 0); } } private: uint8_t m_time = 0; }; InputBlip inputBlip; InputFunc randomPulse([]() { static unsigned int pulse = 0; EVERY_N_MILLISECONDS(25) { if (pulse == 0) { pulse = random(25) + 25; } } if (pulse > 0) { if (random(255) >= 25) { pulse--; return InputEvent{InputEvent::Acceleration, beatsin16(60 * 8, 0, 4972)}; } } return InputEvent{}; }, "Pulse", Task::Running); InputMapper keyMap([](const InputEvent& evt) { if (evt.intent == InputEvent::UserInput) { Buttons::Chord chord = (Buttons::Chord)evt.asInt(); switch(chord) { case Buttons::Circle: return InputEvent::PowerToggle; break; case Buttons::Triangle: return InputEvent::NextPattern; break; case Buttons::Cross: return InputEvent::UserInput; break; } } return InputEvent::None; }); ChimesAnimation chimes{Task::Stopped}; SolidAnimation solid{Task::Running}; DrainAnimation drain{Task::Stopped}; Flashlight flashlight{Task::Stopped}; Sequencer sequencer{{ {"Idle", {"Solid", "MPU5060", "Pulse", "Hackerbots", "Kieryn", "CircadianRhythm"}}, {"Solid", {"Solid", "MPU5060", "Pulse", "CircadianRhythm"}}, {"Interactive", {"Drain", "CircadianRhythm"}}, {"Flashlight", {"Flashlight"}}, {"Nightlight", {"Drain", "Pulse", "Noisebridge"}}, {"Gay", {"Solid", "Pulse", "Rainbow", "Hackerbots", "Kieryn"}}, {"Acid", {"Chimes", "Pulse", "MPU5060", "Hackerbots", "Rainbow"}}, }}; // Render all layers to the displays Renderer renderer{ //{&dpy, &neckDisplay}, {&dpy}, { &chimes, &drain, &solid, &flashlight, Static::instance(), &displayClip, &inputBlip, &power, } }; Renderer configRenderer{ {&dpy}, {&drain, &configDisplay, &inputBlip, &power} }; MDNSService mdnsService; class OnlineTaskMixin : public virtual Loopable { public: void handleEvent(const InputEvent &evt) override { if (evt.intent == InputEvent::NetworkStatus) { m_online = evt.asInt(); } if (m_online) { handleEventOnline(evt); } } virtual void handleEventOnline(const InputEvent &evt) = 0; void loop() override { if (m_online) { loopOnline(); } } virtual void loopOnline() = 0; private: bool m_online = false; }; #include "MQTT/MQTT.h" class MQTTTelemetry : public BufferedInputSource, OnlineTaskMixin { public: MQTTTelemetry() : BufferedInputSource("MQTT"), m_client("relay.malloc.hackerbots.net", 1883, 512, 15, MQTTTelemetry::s_callback, true) { strcpy(m_deviceName, System.deviceID().c_str()); } void loop() override { BufferedInputSource::loop(); OnlineTaskMixin::loop(); } void handleEventOnline(const InputEvent& event) override { char response[255]; if (event.intent == InputEvent::SetPower) { JSONBufferWriter writer(response, sizeof(response)); writer.beginObject(); writer.name("state").value(event.asInt() == 0 ? "OFF" : "ON"); writer.endObject(); writer.buffer()[std::min(writer.bufferSize(), writer.dataSize())] = 0; m_client.publish(m_statTopic, response, MQTT::QOS1); } else if (event.intent == InputEvent::SetBrightness) { JSONBufferWriter writer(response, sizeof(response)); writer.beginObject(); writer.name("brightness").value(event.asInt()); writer.endObject(); writer.buffer()[std::min(writer.bufferSize(), writer.dataSize())] = 0; m_client.publish(m_statTopic, response, MQTT::QOS1); } else if (event.intent == InputEvent::SetColor) { JSONBufferWriter writer(response, sizeof(response)); writer.beginObject(); writer.name("color").beginObject(); CRGB rgb = event.asRGB(); writer.name("r").value(rgb.r); writer.name("g").value(rgb.g); writer.name("b").value(rgb.b); writer.endObject(); writer.endObject(); writer.buffer()[std::min(writer.bufferSize(), writer.dataSize())] = 0; m_client.publish(m_statTopic, response, MQTT::QOS1); } else if (event.intent == InputEvent::SetPattern) { JSONBufferWriter writer(response, sizeof(response)); writer.beginObject(); writer.name("effect").value(event.asString()); writer.endObject(); writer.buffer()[std::min(writer.bufferSize(), writer.dataSize())] = 0; m_client.publish(m_statTopic, response, MQTT::QOS1); } else { if (m_lastIntent != event.intent) { m_lastIntent = event.intent; JSONBufferWriter writer(response, sizeof(response)); writer.beginObject(); writer.name("intent").value(event.intent); writer.endObject(); writer.buffer()[std::min(writer.bufferSize(), writer.dataSize())] = 0; m_client.publish("renderbug/events", response, MQTT::QOS1); } } } void loopOnline() override { if (m_client.isConnected()) { m_client.loop(); EVERY_N_SECONDS(10) { char heartbeatBuf[255]; JSONBufferWriter writer(heartbeatBuf, sizeof(heartbeatBuf)); writer.beginObject(); writer.name("fps").value(FastLED.getFPS()); writer.name("os_version").value(System.version()); writer.name("free_ram").value((unsigned int)System.freeMemory()); //writer.name("uptime").value(System.uptime()); /*WiFiSignal sig = WiFi.RSSI(); writer.name("strength").value(sig.getStrength()); writer.name("quality").value(sig.getQuality());*/ writer.name("RSSI").value(WiFi.RSSI()); writer.name("SSID").value(WiFi.SSID()); //writer.name("MAC").value(WiFi.macAddress()); writer.name("localip").value(WiFi.localIP().toString()); writer.name("startPixel").value(Static::instance()->coordMap()->startPixel); writer.name("pixelCount").value(Static::instance()->coordMap()->pixelCount); writer.endObject(); writer.buffer()[std::min(writer.bufferSize(), writer.dataSize())] = 0; m_client.publish(m_attrTopic, heartbeatBuf); } } else { m_client.connect("renderbug_" + String(m_deviceName) + "_" + String(Time.now())); char response[512]; JSONBufferWriter writer(response, sizeof(response)); String devTopic = String("homeassistant/light/renderbug/") + m_deviceName; writer.beginObject(); writer.name("~").value(devTopic.c_str()); writer.name("name").value("Renderbug"); writer.name("unique_id").value(m_deviceName); writer.name("cmd_t").value("~/set"); writer.name("stat_t").value("~/set"); writer.name("json_attr_t").value("~/attributes"); writer.name("schema").value("json"); writer.name("brightness").value(true); writer.name("rgb").value(true); writer.name("ret").value(true); writer.name("dev").beginObject(); writer.name("name").value("Renderbug"); writer.name("mdl").value("Renderbug"); writer.name("sw").value(RENDERBUG_VERSION); writer.name("mf").value("Phong Robotics"); writer.name("ids").beginArray(); writer.value(m_deviceName); writer.endArray(); writer.endObject(); writer.name("fx_list").beginArray(); for(const Sequencer::Scene& scene : sequencer.scenes()) { writer.value(scene.name); } writer.endArray(); writer.endObject(); writer.buffer()[std::min(writer.bufferSize(), writer.dataSize())] = 0; String configTopic = devTopic + "/config"; m_client.publish(configTopic, response, true); String statTopic = devTopic + "/state"; String cmdTopic = devTopic + "/set"; String attrTopic = devTopic + "/attributes"; strcpy(m_statTopic, statTopic.c_str()); strcpy(m_attrTopic, attrTopic.c_str()); strcpy(m_cmdTopic, cmdTopic.c_str()); m_client.subscribe(m_cmdTopic, MQTT::QOS1); } } private: void callback(char* topic, byte* payload, unsigned int length) { MainLoop::instance()->dispatch(InputEvent::NetworkActivity); if (!strcmp(topic, m_cmdTopic)) { JSONValue cmd = JSONValue::parseCopy((char*)payload, length); JSONObjectIterator cmdIter(cmd); while(cmdIter.next()) { if (cmdIter.name() == "state" && cmdIter.value().toString() == "ON") { setEvent({InputEvent::SetPower, 1}); } else if (cmdIter.name() == "state" && cmdIter.value().toString() == "OFF") { setEvent({InputEvent::SetPower, 0}); } if (cmdIter.name() == "color") { JSONObjectIterator colorIter(cmdIter.value()); uint8_t r, g, b; while(colorIter.next()) { if (colorIter.name() == "r") { r = colorIter.value().toInt(); } else if (colorIter.name() == "g") { g = colorIter.value().toInt(); } else if (colorIter.name() == "b") { b = colorIter.value().toInt(); } } setEvent(InputEvent{InputEvent::SetColor, CRGB{r, g, b}}); } if (cmdIter.name() == "brightness") { uint8_t brightness = cmdIter.value().toInt(); setEvent(InputEvent{InputEvent::SetBrightness, brightness}); } if (cmdIter.name() == "effect") { strcpy(m_patternBuf, (const char*) cmdIter.value().toString()); setEvent(InputEvent{InputEvent::SetPattern, m_patternBuf}); } if (cmdIter.name() == "pixelCount") { setEvent(InputEvent{InputEvent::SetDisplayLength, cmdIter.value().toInt()}); } if (cmdIter.name() == "startPixel") { setEvent(InputEvent{InputEvent::SetDisplayOffset, cmdIter.value().toInt()}); } if (cmdIter.name() == "save") { setEvent(InputEvent{InputEvent::SaveConfigurationRequest}); } } } } static void s_callback(char* topic, byte* payload, unsigned int length) { Static::instance()->callback(topic, payload, length); }; MQTT m_client; InputEvent::Intent m_lastIntent; char m_deviceName[100]; char m_statTopic[100]; char m_attrTopic[100]; char m_cmdTopic[100]; char m_patternBuf[48]; }; STATIC_ALLOC(MQTTTelemetry); WebTelemetry webTelemetry(sequencer); // Cycle some random colors ColorSequenceInput<7> noisebridgeCycle{{colorForName("Red").rgb}, "Noisebridge", Task::Stopped}; ColorSequenceInput<13> kierynCycle{{ colorForName("Cerulean").rgb, colorForName("Electric Purple").rgb, colorForName("Emerald").rgb, colorForName("Sky Magenta").rgb }, "Kieryn", Task::Stopped}; ColorSequenceInput<7> rainbowCycle{{ colorForName("Red").rgb, colorForName("Orange").rgb, colorForName("Yellow").rgb, colorForName("Green").rgb, colorForName("Blue").rgb, colorForName("Purple").rgb, colorForName("White").rgb, }, "Rainbow", Task::Stopped}; ColorSequenceInput<7> hackerbotsCycle{{ colorForName("Purple").rgb, colorForName("White").rgb, colorForName("Cyan").rgb, }, "Hackerbots", Task::Stopped}; struct ConfigInputTask: public BufferedInputSource { public: ConfigInputTask() : BufferedInputSource("ConfigInput") {} void handleEvent(const InputEvent& evt) override { if (evt.intent == InputEvent::UserInput) { Buttons::Chord chord = (Buttons::Chord) evt.asInt(); switch (chord) { case Buttons::Circle: m_currentIntent = nextIntent(); Log.info("Next setting... (%d)", m_currentIntent); break; case Buttons::CircleTriangle: Log.info("Increment..."); increment(); break; case Buttons::CircleCross: Log.info("Decrement..."); decrement(); break; case Buttons::Triangle: Log.info("Save..."); setEvent(InputEvent::SaveConfigurationRequest); break; } } } private: InputEvent::Intent m_currentIntent = InputEvent::SetDisplayLength; void decrement() { int current = 0; switch (m_currentIntent) { case InputEvent::SetDisplayLength: current = Static::instance()->coordMap()->pixelCount; break; case InputEvent::SetDisplayOffset: current = Static::instance()->coordMap()->startPixel; break; } setEvent(InputEvent{m_currentIntent, current - 1}); } void increment() { int current = 0; switch (m_currentIntent) { case InputEvent::SetDisplayLength: current = Static::instance()->coordMap()->pixelCount; break; case InputEvent::SetDisplayOffset: current = Static::instance()->coordMap()->startPixel; break; } setEvent(InputEvent{m_currentIntent, current + 1}); } InputEvent::Intent nextIntent() { switch (m_currentIntent) { case InputEvent::SetDisplayLength: return InputEvent::SetDisplayOffset; case InputEvent::SetDisplayOffset: return InputEvent::SetDisplayLength; } } }; enum Phase { Null, AstronomicalDay, NauticalDay, CivilDay, CivilNight, NauticalNight, AstronomicalNight, Evening, Bedtime, }; struct ScheduleEntry { uint8_t hour; uint8_t brightness; }; std::array schedule{{ {0, 0}, {5, 0}, {6, 0}, {7, 10}, {8, 80}, {11, 120}, {18, 200}, {19, 255}, {22, 120}, {23, 20} }}; uint8_t brightnessForTime(uint8_t hour, uint8_t minute) { ScheduleEntry start = schedule.back(); ScheduleEntry end = schedule.front(); for(ScheduleEntry cur : schedule) { // Find the last hour that is prior to or equal to now if (cur.hour <= hour) { start = cur; } else { break; } } for(ScheduleEntry cur : schedule) { // Find the first hour that is after now // If no such hour exists, we should automatically wrap back to hour 0 if (cur.hour > hour) { end = cur; break; } } if (start.hour > end.hour) { end.hour += 24; } uint16_t startTime = start.hour * 60; uint16_t endTime = end.hour * 60; uint16_t nowTime = hour * 60 + minute; uint16_t duration = endTime - startTime; uint16_t curDuration = nowTime - startTime; uint8_t frac = ((double)curDuration / (double)duration) * 255.0; return lerp8by8(start.brightness, end.brightness, frac); } InputFunc circadianRhythm([]() { static Phase lastPhase = Null; static bool needsUpdate = true; if (Time.isValid()) { EVERY_N_SECONDS(60) { needsUpdate = true; } if (needsUpdate) { needsUpdate = false; return InputEvent{InputEvent::SetBrightness, brightnessForTime(Time.hour(), Time.minute())}; } } return InputEvent{}; }, "CircadianRhythm", Task::Running); // A special mainloop app for configuring hardware settings that reboots the // device when the user is finished. MainLoop configApp{{ // Manage read/write of configuration data Static::instance(), #ifdef PLATFORM_PHOTON // Update photon telemetry Static::instance(), #endif // Read hardware inputs new Buttons(), // Map input buttons to configuration commands new ConfigInputTask(), // Fill the entire display with a color, to see size &configDisplay, // Render some basic input feedback &inputBlip, // Render it all &configRenderer, }}; // Turn on, MainLoop renderbugApp{{ // Load/update graphics configuration from EEPROM and Particle Static::instance(), // Platform inputs #ifdef PLATFORM_PHOTON // Particle cloud status Static::instance(), // Monitor network state and provide particle API events Static::instance(), #endif // Hardware drivers new MPU5060(), new Buttons(), // Map buttons to events &keyMap, // Pattern sequencer &sequencer, // Daily rhythm activities &circadianRhythm, // Periodic motion input &randomPulse, // Periodic color inputs &noisebridgeCycle, &kierynCycle, &rainbowCycle, &hackerbotsCycle, // Animations &chimes, &drain, &solid, &flashlight, // Update UI layer &power, &displayClip, Static::instance(), &inputBlip, // Render everything &renderer, // Platform telemetry #ifdef PLATFORM_PHOTON // Update photon telemetry Static::instance(), // Web telemetry UI &webTelemetry, // MQTT telemetry Static::instance(), // Network discovery &mdnsService, #endif }}; MainLoop &runner = renderbugApp; retained bool LAST_BOOT_WAS_FLASH; retained bool LAST_BOOT_WAS_SERIAL; struct BootOptions { static void initPins() { pinMode(2, INPUT_PULLDOWN); pinMode(3, INPUT_PULLDOWN); pinMode(4, INPUT_PULLDOWN); } BootOptions() { isSetup = digitalRead(2) == HIGH; isSerial = digitalRead(3) == HIGH || LAST_BOOT_WAS_SERIAL; isFlash = digitalRead(4) == HIGH; LAST_BOOT_WAS_FLASH = isFlash; LAST_BOOT_WAS_SERIAL |= isSerial; configStatus.setActive(isSetup); serialStatus.setActive(isSerial); } void waitForRelease() { while(digitalRead(2) == HIGH || digitalRead(3) == HIGH) {}; } bool isSetup = false; bool isSerial = false; bool isFlash = false; LEDStatus serialStatus = LEDStatus(RGB_COLOR_ORANGE, LED_PATTERN_FADE, LED_SPEED_FAST, LED_PRIORITY_BACKGROUND); LEDStatus configStatus = LEDStatus(RGB_COLOR_YELLOW, LED_PATTERN_FADE, LED_SPEED_NORMAL, LED_PRIORITY_IMPORTANT); }; STARTUP(BootOptions::initPins()); retained BootOptions bootopts; ApplicationWatchdog *wd; void watchdogHandler() { for(int i = 0; i < 8; i++) { leds[i] = CRGB(i % 3 ? 35 : 255, 0, 0); } FastLED.show(); if (LAST_BOOT_WAS_FLASH) { System.dfu(); } else { System.enterSafeMode(); } } // Tune in, void setup() { System.enableFeature(FEATURE_RETAINED_MEMORY); wd = new ApplicationWatchdog(5000, watchdogHandler, 1536); Serial.begin(115200); if (bootopts.isFlash) { System.dfu(); } if (bootopts.isSerial) { bootopts.waitForRelease(); while(!Serial.isConnected()) { #ifdef PLATFORM_PHOTON Particle.process(); #endif } Log.info("\xf0\x9f\x94\x8c Serial connected"); } Log.info(u8"🐛 Booting Renderbug %s!", System.deviceID().c_str()); Log.info(u8"🐞 I am built for %d LEDs running on %dmA", HardwareConfig::MAX_LED_NUM, PSU_MILLIAMPS); #ifdef PLATFORM_PHOTON Log.info(u8"📡 Particle version %s", System.version().c_str()); #endif Log.info(u8" Boot pin configuration:"); Log.info(u8" 2: Setup - %d", bootopts.isSetup); Log.info(u8" 3: Serial - %d", bootopts.isSerial); Log.info(u8" 4: Flash - %d", bootopts.isFlash); Log.info(u8" Setting timezone to UTC-7"); Time.zone(-7); Log.info(u8"💡 Starting FastLED..."); FastLED.addLeds(leds, HardwareConfig::MAX_LED_NUM); if (bootopts.isSetup) { Log.info(u8"🌌 Starting Figment in configuration mode..."); runner = configApp; } else { Log.info(u8"🌌 Starting Figment..."); } Serial.flush(); runner.start(); Log.info(u8"💽 %lu bytes of free RAM", System.freeMemory()); Log.info(u8"🚀 Setup complete! Ready to rock and roll."); Serial.flush(); } // Drop out. void loop() { runner.loop(); wd->checkin(); }