|
/* |
|
* StoryBot 1.0 |
|
* Copyright 2022 Dana Sniezko, GPLv3 |
|
* |
|
* A simple ESP32 robot that tells stories. |
|
* Connects to Wifi, uses GPT-3 to generate stories, |
|
* and a thermal printer to print them out! |
|
* |
|
* This program is free software: you can redistribute it and/or modify |
|
* it under the terms of the GNU General Public License as published by |
|
* the Free Software Foundation, either version 3 of the License, or |
|
* (at your option) any later version. |
|
* |
|
* This program is distributed in the hope that it will be useful, |
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
* GNU General Public License for more details. |
|
* |
|
* <https://www.gnu.org/licenses/gpl-3.0.html> |
|
* |
|
*/ |
|
|
|
#include "Adafruit_Thermal.h" |
|
#include <ArduinoOTA.h> |
|
#include <ArduinoJson.h> |
|
#include <ESPmDNS.h> |
|
#include <HTTPClient.h> |
|
#include <WiFi.h> |
|
|
|
// Setup pins for thermal printer serial and push button |
|
#define RXD2 16 |
|
#define TXD2 17 |
|
#define BUTTON_PIN 21 |
|
unsigned long debounceDelay = 5000; // 5 seconds |
|
|
|
|
|
// Settings for OpenAI API |
|
#define AI_URI "https://api.openai.com/v1/completions" |
|
#define AI_KEY "Bearer sk-xyzxxxx" |
|
#define USER_AGENT "Storybot 1.0 (https://storybot.lol/about)" |
|
#define STORY_SIZE 150 |
|
#define DEFAULT_PROMPT "Write a funny story about the anarchist hackerspace:" |
|
#define AI_TIMEOUT 30000 |
|
#define RETRIES 4 |
|
|
|
// Device name for OTA updates |
|
#define DEVICE_NAME "storybot10" |
|
#define DEVICE_PASS "storybot" |
|
|
|
// Web App - set to 0 if you don't want to use a web app |
|
// This allows you to save stories to a database and post to mastodon |
|
#define USE_WEB_APP 0 |
|
#define STORYBOT_KEY "ASDFQWERTY" |
|
|
|
// if you want to post stories to Mastodon |
|
#define MASTO_KEY "Bearer xyzzzzzzz" |
|
#define MASTO_URI "https://botsin.space/api/v1/statuses" |
|
|
|
// If you have multiple storybots, you can change the device name to differentiate them |
|
#define DEVICE_ID 100 |
|
|
|
// Be REALLY verbose in Serial output |
|
#define DEBUG 1 |
|
|
|
Adafruit_Thermal printer(&Serial2); // Pass addr tbo printer constructor |
|
|
|
// Include various Wifi networks you want to connect to here, it'll cycle through them |
|
|
|
#define NUM_NETWORKS 3 |
|
const char* wifi[][2] = { |
|
{"Noisebridge Cap",""}, |
|
{"Noisebridge","noisebridge"} |
|
}; |
|
|
|
unsigned long lastTime = 0; |
|
int counter = 0; |
|
|
|
void setup() { |
|
Serial.begin(115200); |
|
Serial2.begin(19200, SERIAL_8N1, RXD2, TXD2); |
|
pinMode(BUTTON_PIN, INPUT_PULLUP); |
|
|
|
// Set up wifi |
|
tryWifiNetworks(); |
|
|
|
OTASetup(); |
|
|
|
printer.begin(); |
|
printer.setDefault(); |
|
|
|
if(USE_WEB_APP) { phoneHome(DEVICE_ID); } |
|
} |
|
|
|
void loop() { |
|
// handle OTA if needed |
|
ArduinoOTA.handle(); |
|
counter = 0; |
|
// reconnect to wifi if disconnected |
|
checkWifi(); |
|
|
|
|
|
// Check if button is pressed and debounce, wait 5 seconds before allowing printing again |
|
if (digitalRead(BUTTON_PIN) == LOW && millis() - lastTime > debounceDelay) { |
|
|
|
lastTime = millis(); |
|
Serial.println("Button pressed"); |
|
|
|
// Get the story from the API |
|
Serial.println("Generating and parsing story..."); |
|
String res = getStory(DEFAULT_PROMPT,STORY_SIZE); |
|
|
|
// If there's a valid story print it out |
|
if(res.length() > 5) { |
|
// Print the story |
|
Serial.println("Printing story..."); |
|
printStory(res); |
|
|
|
} |
|
} |
|
} |
|
|
|
// API-post request story |
|
String getStory(String prompt, int length) { |
|
counter++; |
|
|
|
// HTTPClient setup stuff |
|
HTTPClient http; |
|
http.setUserAgent(USER_AGENT); |
|
|
|
// set this timeout much higher |
|
http.setTimeout(AI_TIMEOUT); |
|
int responseCode = 0; |
|
|
|
http.begin(AI_URI); |
|
http.addHeader("Content-Type", "application/json"); |
|
http.addHeader("Authorization", AI_KEY); |
|
http.addHeader("User-Agent", USER_AGENT); |
|
String payload = "{\"model\":\"text-davinci-003\",\"prompt\":\""+prompt+"\",\"max_tokens\":"+String(length)+",\"temperature\":0.9,\"top_p\":1,\"frequency_penalty\":0.2,\"presence_penalty\":0.1, \"stop\":\"\"}"; |
|
responseCode = http.POST(payload); |
|
|
|
if(DEBUG) { |
|
Serial.println("Response code: " + String(responseCode)); |
|
} |
|
|
|
if(responseCode == 200) { |
|
// Parse response |
|
Serial.println("Parsing JSON..."); |
|
StaticJsonDocument<400> filter; |
|
filter["choices"][0]["text"] = true; |
|
|
|
DynamicJsonDocument doc(2048); |
|
deserializeJson(doc, http.getStream(),DeserializationOption::Filter(filter)); |
|
|
|
if(DEBUG) { |
|
Serial.println("What the JSON body looks like"); |
|
serializeJsonPretty(doc, Serial); |
|
} |
|
http.end(); |
|
|
|
// Let's save the story to a remote database and post to mastodon |
|
if(USE_WEB_APP) { |
|
Serial.println("Saving story to database..."); |
|
// deal with mastodon, len 500 chars |
|
sendToMastodon(doc["choices"][0]["text"],"0"); |
|
sendStoryToDB(doc["choices"][0]["text"]); |
|
} |
|
return doc["choices"][0]["text"].as<String>(); |
|
} else { |
|
Serial.println("Story generation failed."); |
|
Serial.println("Response body: " + http.getString()); |
|
} |
|
http.end(); |
|
|
|
// if something fails with our api call, retry it up to the retry limit |
|
if(counter < RETRIES) { |
|
Serial.println("Something failed. Waiting a few seconds and trying again..."); |
|
delay(5000); |
|
return getStory(prompt,length); |
|
} else { |
|
return String(""); |
|
} |
|
} |
|
|
|
// sends to your mastodon instance |
|
void sendToMastodon(String story,String replyId) { |
|
HTTPClient http; |
|
http.setUserAgent(USER_AGENT); |
|
int responseCode = 0; |
|
http.begin(MASTO_URI); |
|
http.addHeader("Authorization", MASTO_KEY); |
|
String payload = "status="+story.substring(0,499); |
|
if(replyId.length() > 10) { |
|
payload = "in_reply_to_id="+String(replyId)+"&status="+story.substring(0,499); |
|
} |
|
responseCode = http.POST(payload); |
|
DynamicJsonDocument doc(1024); |
|
deserializeJson(doc, http.getStream()); |
|
if(DEBUG) { |
|
Serial.println("Response code: " + String(responseCode)); |
|
Serial.println("Payload: " + payload); |
|
//Serial.println(http.getString()); |
|
serializeJsonPretty(doc, Serial); |
|
} |
|
|
|
if (responseCode == 200) { |
|
// recursive call, there's still more to the body so we need to create additional posts |
|
if(story.length() > 499) { |
|
delay(1000); |
|
sendToMastodon(story.substring(499),doc["id"]); |
|
} |
|
} |
|
http.end(); |
|
} |
|
|
|
void sendStoryToDB(String story) { |
|
HTTPClient http; |
|
http.setUserAgent(USER_AGENT); |
|
int responseCode = 0; |
|
http.begin("https://storybot.lol/save_story"); |
|
http.addHeader("Content-Type", "application/json"); |
|
http.addHeader("Authorization", "abc123"); |
|
responseCode = http.POST("{\"key\":\""+String(STORYBOT_KEY)+"\"\"story\":\""+story+"\"}"); |
|
if(DEBUG) { |
|
Serial.println("Response code: " + String(responseCode)); |
|
Serial.println("Response body: " + http.getString()); |
|
} |
|
http.end(); |
|
} |
|
|
|
void phoneHome(int id) { |
|
HTTPClient http; |
|
http.setUserAgent(USER_AGENT); |
|
int responseCode = 0; |
|
http.begin("https://storybot.lol/checkin"); |
|
http.addHeader("Content-Type", "application/json"); |
|
String payload = "{\"message\":\"hello\",\"id\":"+String(id)+"}"; |
|
responseCode = http.POST(payload); |
|
http.end(); |
|
|
|
// To Do: get a prompt from your web app. |
|
} |
|
|
|
// Print story to thermal printer |
|
void printStory(String story) { |
|
|
|
// formatting is a PAIN |
|
printer.setDefault(); |
|
printer.wake(); |
|
printer.setSize('M'); |
|
printer.boldOn(); |
|
printer.println("*** HACKERSPACE STORIES ***"); |
|
printer.doubleHeightOff(); |
|
printer.boldOff(); |
|
printer.setSize('S'); |
|
printer.println("Written by AI. storybot.lol"); |
|
printer.print(DEFAULT_PROMPT); |
|
printer.println(story); |
|
printer.feed(2); |
|
printer.sleep(); |
|
if(DEBUG) { Serial.println("Printing..."+story); } |
|
} |
|
|
|
void OTASetup(){ |
|
// Hostname defaults to esp3232-[MAC] |
|
ArduinoOTA.setHostname(DEVICE_NAME); |
|
|
|
// No authentication by default |
|
ArduinoOTA.setPassword(DEVICE_PASS); |
|
|
|
ArduinoOTA |
|
.onStart([]() { |
|
String type; |
|
if (ArduinoOTA.getCommand() == U_FLASH) |
|
type = "sketch"; |
|
else // U_SPIFFS |
|
type = "filesystem"; |
|
|
|
// NOTE: if updating SPIFFS this would be the place to unmount SPIFFS using SPIFFS.end() |
|
Serial.println("Start updating " + type); |
|
}) |
|
.onEnd([]() { |
|
Serial.println("\nEnd"); |
|
}) |
|
.onProgress([](unsigned int progress, unsigned int total) { |
|
Serial.printf("Progress: %u%%\r", (progress / (total / 100))); |
|
}) |
|
.onError([](ota_error_t error) { |
|
Serial.printf("Error[%u]: ", error); |
|
if (error == OTA_AUTH_ERROR) Serial.println("Auth Failed"); |
|
else if (error == OTA_BEGIN_ERROR) Serial.println("Begin Failed"); |
|
else if (error == OTA_CONNECT_ERROR) Serial.println("Connect Failed"); |
|
else if (error == OTA_RECEIVE_ERROR) Serial.println("Receive Failed"); |
|
else if (error == OTA_END_ERROR) Serial.println("End Failed"); |
|
}); |
|
|
|
ArduinoOTA.begin(); |
|
} |
|
|
|
void tryWifiNetworks() { |
|
for (int i = 0; i < NUM_NETWORKS; i++) { |
|
int counter = 0; |
|
Serial.print("Trying to connect to WiFi network: "); |
|
Serial.println(wifi[i][0]); |
|
WiFi.begin(wifi[i][0], wifi[i][1]); |
|
while (WiFi.status() != WL_CONNECTED && counter < 30) { |
|
delay(500); |
|
Serial.print("."); |
|
counter++; |
|
} |
|
|
|
if(WiFi.status() == WL_CONNECTED) { |
|
// we found a network so let's be done with this. |
|
Serial.print("Connected to WiFi network: "); |
|
Serial.println(wifi[i][0]); |
|
//Serial.println("IP address: "+WiFi.localIP()); |
|
break; |
|
} |
|
} |
|
// if we still aren't connected for some reason, let's try again... |
|
if(WiFi.status() != WL_CONNECTED) { |
|
Serial.println("No wifi connection established. Waiting 10 seconds and trying again."); |
|
delay(10000); |
|
tryWifiNetworks(); |
|
} |
|
} |
|
|
|
void checkWifi() { |
|
if (WiFi.status() != WL_CONNECTED) { |
|
Serial.println("Reconnecting to WiFi..."); |
|
WiFi.disconnect(); |
|
WiFi.reconnect(); |
|
delay(100); |
|
} |
|
} |