renderbug-cpp/firmware/main.cpp

734 lines
21 KiB
C++
Raw Normal View History

2021-03-28 01:19:55 +00:00
#define RENDERBUG_VERSION 1
#define PLATFORM_PHOTON
//#define PLATFORM_ESP2800
2019-05-10 05:17:29 +00:00
#include "FastLED/FastLED.h"
#include "Figments/Figments.h"
2018-12-27 04:35:58 +00:00
2019-05-10 05:17:29 +00:00
#include "Static.h"
#include "Config.h"
#include "colors.h"
2018-12-27 04:35:58 +00:00
2021-03-28 01:19:55 +00:00
#include "WebTelemetry.cpp"
#include "MDNSService.cpp"
#include "Sequencer.h"
2019-05-10 05:17:29 +00:00
#include "animations/Power.cpp"
#include "animations/SolidAnimation.cpp"
#include "animations/Chimes.cpp"
#include "animations/Flashlight.cpp"
2021-03-28 01:19:55 +00:00
#include "animations/Drain.cpp"
2019-05-10 05:17:29 +00:00
#include "animations/UpdateStatus.h"
#include "inputs/ColorCycle.h"
#include "inputs/Buttons.h"
2021-03-28 01:19:55 +00:00
#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
2019-05-10 05:17:29 +00:00
SerialLogHandler logHandler;
using namespace NSFastLED;
#define MAX_BRIGHTNESS 255
2021-03-28 01:19:55 +00:00
//#define PSU_MILLIAMPS 4800
//#define PSU_MILLIAMPS 500
#define PSU_MILLIAMPS 1000
2019-05-10 05:17:29 +00:00
// Enable system thread, so rendering happens while booting
SYSTEM_THREAD(ENABLED);
// Setup FastLED and the display
2021-03-28 01:19:55 +00:00
CRGB leds[HardwareConfig::MAX_LED_NUM];
Display dpy(leds, HardwareConfig::MAX_LED_NUM, Static<ConfigService>::instance()->coordMap());
2019-05-10 05:17:29 +00:00
LinearCoordinateMapping neckMap{60, 0};
2021-03-28 01:19:55 +00:00
Display neckDisplay(leds, HardwareConfig::MAX_LED_NUM, &neckMap);
2019-05-10 05:17:29 +00:00
// Setup power management
Power<MAX_BRIGHTNESS, PSU_MILLIAMPS> power;
2021-03-28 01:19:55 +00:00
// Clip the display at whatever is configured while still showing over-paints
FigmentFunc displayClip([](Display* dpy) {
auto coords = Static<ConfigService>::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;
2019-05-10 05:17:29 +00:00
}
}
2021-03-28 01:19:55 +00:00
});
2019-05-10 05:17:29 +00:00
2021-03-28 01:19:55 +00:00
FigmentFunc configDisplay([](Display* dpy) {
uint8_t brightness = brighten8_video(beatsin8(60));
auto coords = Static<ConfigService>::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);
2019-05-10 05:17:29 +00:00
}
}
2021-03-28 01:19:55 +00:00
});
2019-05-10 05:17:29 +00:00
2021-03-28 01:19:55 +00:00
class InputBlip: public Figment {
public:
InputBlip() : Figment("InputBlip") {}
2019-05-10 05:17:29 +00:00
2021-03-28 01:19:55 +00:00
void handleEvent(const InputEvent& evt) override {
if (evt.intent != InputEvent::None) {
m_time = qadd8(m_time, 5);
}
2019-05-10 05:17:29 +00:00
}
2021-03-28 01:19:55 +00:00
void loop() override {
if (m_time > 0) {
m_time--;
2019-05-10 05:17:29 +00:00
}
2021-03-28 01:19:55 +00:00
}
void render(Display* dpy) const override {
if (m_time > 0) {
dpy->pixelAt(0) = CRGB(0, brighten8_video(ease8InOutApprox(m_time)), 0);
2019-05-10 05:17:29 +00:00
}
}
2021-03-28 01:19:55 +00:00
private:
uint8_t m_time = 0;
2019-05-10 05:17:29 +00:00
};
2021-03-28 01:19:55 +00:00
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)};
}
2019-05-10 05:17:29 +00:00
}
2021-03-28 01:19:55 +00:00
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;
}
2019-05-10 05:17:29 +00:00
}
2021-03-28 01:19:55 +00:00
return InputEvent::None;
2019-05-10 05:17:29 +00:00
});
2018-12-27 04:35:58 +00:00
2019-05-10 05:17:29 +00:00
ChimesAnimation chimes{Task::Stopped};
2021-03-28 01:19:55 +00:00
SolidAnimation solid{Task::Running};
DrainAnimation drain{Task::Stopped};
2019-05-10 05:17:29 +00:00
Flashlight flashlight{Task::Stopped};
2021-03-28 01:19:55 +00:00
Sequencer sequencer{{
{"Solid", {"Solid"}},
{"Drain", {"Drain"}},
{"Flashlight", {"Flashlight"}},
{"Fiercewater", {"Solid", "Kieryn", "Hackerbots"}},
{"Nightlight", {"Drain", "Noisebridge"}},
{"Gay", {"Solid", "Rainbow", "Hackerbots", "Kieryn"}},
{"Acid", {"Chimes", "Hackerbots", "Rainbow"}},
}};
2019-05-10 05:17:29 +00:00
// Render all layers to the displays
Renderer renderer{
//{&dpy, &neckDisplay},
{&dpy},
{
&chimes,
&drain,
&solid,
&flashlight,
Static<UpdateStatus>::instance(),
&displayClip,
2021-03-28 01:19:55 +00:00
&inputBlip,
2019-05-10 05:17:29 +00:00
&power,
}
};
2021-03-28 01:19:55 +00:00
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 = true;
}
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 Task, OnlineTaskMixin {
public:
MQTTTelemetry() : Task("MQTT"), m_client("relay.malloc.hackerbots.net", 1883, 512, 15, MQTTTelemetry::s_callback, true) {
s_instance = this;
strcpy(m_deviceName, System.deviceID().c_str());
}
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 {
/*root["intent"] = event.intent;
switch(event.type) {
case InputEvent::Null:
root["value"] = 0;break;
case InputEvent::Integer:
root["value"] = event.asInt();break;
case InputEvent::String:
root["value"] = event.asString();break;
case InputEvent::Color:
root["value"] = "RGB";break;
}
//root.printTo(response, sizeof(response));
//m_client.publish("renderbug/event", response);*/
}
}
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(NSFastLED::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.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::parse((char*)payload, length);
JSONObjectIterator cmdIter(cmd);
while(cmdIter.next()) {
if (cmdIter.name() == "state" && cmdIter.value().toString() == "ON") {
MainLoop::instance()->dispatch({InputEvent::SetPower, 1});
} else if (cmdIter.name() == "state" && cmdIter.value().toString() == "OFF") {
MainLoop::instance()->dispatch({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();
}
}
MainLoop::instance()->dispatch(InputEvent{InputEvent::SetColor, CRGB{r, g, b}});
}
if (cmdIter.name() == "brightness") {
uint8_t brightness = cmdIter.value().toInt();
MainLoop::instance()->dispatch(InputEvent{InputEvent::SetBrightness, brightness});
}
}
}
}
static void s_callback(char* topic, byte* payload, unsigned int length) {
s_instance->callback(topic, payload, length);
};
static MQTTTelemetry* s_instance;
MQTT m_client;
char m_deviceName[100];
char m_statTopic[100];
char m_attrTopic[100];
char m_cmdTopic[100];
};
MQTTTelemetry mqttTelemetry;
MQTTTelemetry* MQTTTelemetry::s_instance = 0;
WebTelemetry webTelemetry(sequencer);
2019-05-10 05:17:29 +00:00
// 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::Running};
ColorSequenceInput<7> rainbowCycle{{
colorForName("Red").rgb,
colorForName("Orange").rgb,
colorForName("Yellow").rgb,
colorForName("Green").rgb,
colorForName("Blue").rgb,
colorForName("Purple").rgb,
colorForName("White").rgb,
2021-03-28 01:19:55 +00:00
}, "Rainbow", Task::Stopped};
ColorSequenceInput<7> hackerbotsCycle{{
colorForName("Purple").rgb,
colorForName("White").rgb,
colorForName("Cyan").rgb,
}, "Hackerbots", Task::Running};
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<ConfigService>::instance()->coordMap()->pixelCount;
break;
case InputEvent::SetDisplayOffset:
current = Static<ConfigService>::instance()->coordMap()->startPixel;
break;
}
setEvent(InputEvent{m_currentIntent, current - 1});
}
void increment() {
int current = 0;
switch (m_currentIntent) {
case InputEvent::SetDisplayLength:
current = Static<ConfigService>::instance()->coordMap()->pixelCount;
break;
case InputEvent::SetDisplayOffset:
current = Static<ConfigService>::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<ScheduleEntry, 10> 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;
EVERY_N_SECONDS(60) {
if (Time.isValid()) {
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<ConfigService>::instance(),
#ifdef PLATFORM_PHOTON
// Update photon telemetry
Static<PhotonTelemetry>::instance(),
#endif
// Read hardware inputs
new Buttons(),
&randomPulse,
//new MPU5060(),
// Map input buttons to configuration commands
new ConfigInputTask(),
// Fill the entire display with a color, to see size
&rainbowCycle,
&drain,
&configDisplay,
// Render some basic input feedback
&inputBlip,
// Render it all
&configRenderer,
}};
2019-05-10 05:17:29 +00:00
// Turn on,
2021-03-28 01:19:55 +00:00
MainLoop renderbugApp{{
2019-05-10 05:17:29 +00:00
// Load/update graphics configuration from EEPROM and Particle
Static<ConfigService>::instance(),
2021-03-28 01:19:55 +00:00
// Platform inputs
#ifdef PLATFORM_PHOTON
2019-05-10 05:17:29 +00:00
// Particle cloud status
Static<CloudStatus>::instance(),
// Monitor network state and provide particle API events
Static<PhotonInput>::instance(),
2021-03-28 01:19:55 +00:00
#endif
2019-05-10 05:17:29 +00:00
2021-03-28 01:19:55 +00:00
// Hardware drivers
2019-05-10 05:17:29 +00:00
new MPU5060(),
new Buttons(),
2021-03-28 01:19:55 +00:00
// Map buttons to events
&keyMap,
// Pattern sequencer
&sequencer,
// Daily rhythm activities
&circadianRhythm,
// Periodic motion input
&randomPulse,
2019-05-10 05:17:29 +00:00
// Periodic color inputs
&noisebridgeCycle,
&kierynCycle,
&rainbowCycle,
2021-03-28 01:19:55 +00:00
&hackerbotsCycle,
2019-05-10 05:17:29 +00:00
// Animations
&chimes,
&drain,
&solid,
&flashlight,
// Update UI layer
&power,
&displayClip,
Static<UpdateStatus>::instance(),
2021-03-28 01:19:55 +00:00
&inputBlip,
2019-05-10 05:17:29 +00:00
// Render everything
&renderer,
2021-03-28 01:19:55 +00:00
// Platform telemetry
#ifdef PLATFORM_PHOTON
2019-05-10 05:17:29 +00:00
// Update photon telemetry
2021-03-28 01:19:55 +00:00
Static<PhotonTelemetry>::instance(),
// Web telemetry UI
&webTelemetry,
// MQTT telemetry
&mqttTelemetry,
// Network discovery
&mdnsService,
#endif
2019-05-10 05:17:29 +00:00
}};
2021-03-28 01:19:55 +00:00
MainLoop &runner = renderbugApp;
struct BootOptions {
BootOptions() {
pinMode(2, INPUT_PULLDOWN);
pinMode(3, INPUT_PULLDOWN);
pinMode(4, INPUT_PULLDOWN);
isSetup = digitalRead(2) == HIGH;
isSerial = digitalRead(3) == HIGH;
isFlash = digitalRead(4) == HIGH;
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);
};
BootOptions bootopts;
ApplicationWatchdog *wd;
void watchdogHandler() {
System.enterSafeMode();
}
2019-05-10 05:17:29 +00:00
// Tune in,
void setup() {
2021-03-28 01:19:55 +00:00
wd = new ApplicationWatchdog(5000, watchdogHandler, 1536);
2019-05-10 05:17:29 +00:00
Serial.begin(115200);
2021-03-28 01:19:55 +00:00
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<NEOPIXEL, 6>(leds, HardwareConfig::MAX_LED_NUM);
if (bootopts.isSetup) {
Log.info(u8"🌌 Starting Figment in configuration mode...");
runner = configApp;
} else {
Log.info(u8"🌌 Starting Figment...");
}
2019-05-10 05:17:29 +00:00
Serial.flush();
runner.start();
2021-03-28 01:19:55 +00:00
Log.info(u8"💽 %lu bytes of free RAM", System.freeMemory());
Log.info(u8"🚀 Setup complete! Ready to rock and roll.");
2019-05-10 05:17:29 +00:00
Serial.flush();
}
2018-12-27 04:35:58 +00:00
2019-05-10 05:17:29 +00:00
// Drop out.
void loop() {
runner.loop();
2021-03-28 01:19:55 +00:00
wd->checkin();
2018-12-27 04:35:58 +00:00
}