#include #include #include #include #include #include #include #include #include #include "ss_qr_bitmap.h" #include "ss_enviro_bitmap.h" #include "ss_survey_bitmap.h" #include "ss_resources_bitmap.h" // ===== Hardware ===== #define EPD_CS 2 #define EPD_DC 3 #define EPD_RST 4 #define EPD_BUSY 43 #define LED_PIN 1 GxEPD2_BW display(GxEPD2_420_GDEY042T81(EPD_CS, EPD_DC, EPD_RST, EPD_BUSY)); TwoWire I2CBus = TwoWire(0); Adafruit_ADXL343 accel = Adafruit_ADXL343(12345, &I2CBus); // ===== Wi-Fi / server ===== const char* AP_SSID = "StoryStone"; const char* AP_PASS = nullptr; WebServer server(80); bool apActive = false; // ===== Storage ===== struct Story { uint64_t id; String type; String text; uint64_t ts; }; std::vector stories; const char* DB_PATH = "/stories.json"; const size_t MAX_STORIES = 1000; const size_t MAX_STORY_LENGTH = 200; const size_t MIN_STORY_LENGTH = 1; uint64_t epochCounter = 0; // ===== Page navigation ===== int currentPage = 1; const int TOTAL_PAGES = 18; unsigned long stateStartTime = 0; const unsigned long TIMEOUT_MS = 120000UL; // 2 minutes // ===== Accelerometer / tap detection ===== float lastX = 0, lastY = 0, lastZ = 0; const float MOVEMENT_THRESHOLD = 4.0f; // Normal tap threshold for pages 2+ const float HOMEPAGE_THRESHOLD = 6.0f; // Firmer tap needed for homepage unsigned long lastRawTap = 0; const unsigned long RAW_DEBOUNCE_MS = 500UL; const unsigned long PAGE_ADVANCE_COOLDOWN_MS = 1350UL; // 1.5 second cooldown const unsigned long HOMEPAGE_ADVANCE_COOLDOWN_MS = 1800UL; // 2 second cooldown after homepage unsigned long lastPageAdvanceTime = 0; // ===== Utility ===== uint64_t nowEpoch() { return millis() / 1000ULL; } uint64_t nextId() { return (nowEpoch() << 16) | (epochCounter++ & 0xFFFFULL); } String htmlEscape(const String& input) { String output; output.reserve(input.length() * 1.2); for (size_t i = 0; i < input.length(); i++) { char c = input[i]; switch (c) { case '&': output += "&"; break; case '<': output += "<"; break; case '>': output += ">"; break; case '"': output += """; break; case '\'': output += "'"; break; default: output += c; break; } } return output; } // ===== LittleFS DB ===== bool loadDB() { if (!LittleFS.exists(DB_PATH)) return true; File f = LittleFS.open(DB_PATH, "r"); if (!f) return false; size_t size = f.size(); std::unique_ptr buf(new char[size + 1]); f.readBytes(buf.get(), size); buf[size] = 0; f.close(); DynamicJsonDocument doc(size * 2 + 1024); auto err = deserializeJson(doc, buf.get()); if (err) { Serial.print("DB parse error: "); Serial.println(err.c_str()); return false; } JsonArray arr = doc["stories"].as(); stories.clear(); for (JsonObject o : arr) { Story s; s.id = o["id"].as(); s.type = String((const char*)o["type"]); s.text = String((const char*)o["text"]); s.ts = o["ts"].as(); stories.push_back(s); } Serial.printf("Loaded %u stories\n", (unsigned)stories.size()); return true; } bool saveDB() { const size_t capacity = JSON_ARRAY_SIZE(stories.size()) + stories.size() * JSON_OBJECT_SIZE(4) + 8192; DynamicJsonDocument doc(capacity); JsonArray arr = doc.createNestedArray("stories"); for (auto &s : stories) { JsonObject o = arr.createNestedObject(); o["id"] = s.id; o["type"] = s.type; o["text"] = s.text; o["ts"] = s.ts; } File f = LittleFS.open(DB_PATH, "w"); if (!f) return false; serializeJson(doc, f); f.close(); return true; } void appendStory(const String &type, const String &text) { if (stories.size() >= MAX_STORIES) stories.erase(stories.begin()); Story s; s.id = nextId(); s.type = type; s.text = text; s.ts = nowEpoch(); stories.push_back(s); saveDB(); } // ===== Text helpers ===== void drawCenteredText(const char* text, int y, int textSize=2) { int16_t tbx,tby; uint16_t tbw,tbh; display.setTextSize(textSize); display.getTextBounds(text, 0, 0, &tbx, &tby, &tbw, &tbh); int16_t x = ((display.width() - tbw) / 2) - tbx; display.setCursor(x, y); display.print(text); } void drawCenteredParagraph(const String &text, int startY, int maxWidth, int lineHeight) { display.setTextSize(2); std::vector lines; String currentLine = ""; String word = ""; for (size_t i = 0; i <= text.length(); ++i) { char c = (i < text.length()) ? text[i] : ' '; if (c == ' ' || c == '\n' || i == text.length()) { String testLine = currentLine; if (testLine.length() > 0) testLine += " "; testLine += word; int16_t tbx, tby; uint16_t tbw, tbh; display.getTextBounds(testLine.c_str(), 0, 0, &tbx, &tby, &tbw, &tbh); if (tbw > maxWidth && currentLine.length() > 0) { lines.push_back(currentLine); currentLine = word; } else { currentLine = testLine; } if (c == '\n' || i == text.length()) { if (currentLine.length() > 0) { lines.push_back(currentLine); } currentLine = ""; } word = ""; } else { word += c; } } int y = startY; for (const String& line : lines) { int16_t tbx, tby; uint16_t tbw, tbh; display.getTextBounds(line.c_str(), 0, 0, &tbx, &tby, &tbw, &tbh); int16_t x = ((display.width() - tbw) / 2) - tbx; display.setCursor(x, y); display.print(line); y += lineHeight; } } // ===== EPD helpers ===== bool getSrcBit(const unsigned char* bmp, int w, int h, int sx, int sy) { int bytesPerLine = (w + 7) / 8; int byteIndex = sy * bytesPerLine + (sx / 8); unsigned char b = pgm_read_byte(bmp + byteIndex); int bit = 7 - (sx % 8); return (b >> bit) & 1; } void drawScaledBitmap(const unsigned char* src, int srcW, int srcH, int dstX, int dstY, int dstW, int dstH) { for (int yy = 0; yy < dstH; ++yy) { int sy = (yy * srcH) / dstH; for (int xx = 0; xx < dstW; ++xx) { int sx = (xx * srcW) / dstW; if (getSrcBit(src, srcW, srcH, sx, sy)) display.drawPixel(dstX + xx, dstY + yy, GxEPD_BLACK); } } } void drawInvertedBitmap(const unsigned char* bitmap, int16_t x, int16_t y, int16_t w, int16_t h, uint16_t color) { int16_t byteWidth = (w + 7) / 8; uint8_t byte = 0; for (int16_t j = 0; j < h; j++) { for (int16_t i = 0; i < w; i++) { if (i & 7) { byte <<= 1; } else { byte = pgm_read_byte(&bitmap[j * byteWidth + i / 8]); } if (!(byte & 0x80)) { display.drawPixel(x + i, y + j, color); } } } } // ===== Page Drawing Functions ===== void drawPage1() { display.firstPage(); do { display.fillScreen(GxEPD_WHITE); display.setTextSize(4); drawCenteredText("Welcome to the", 60, 4); drawCenteredText("MIT StoryStone!", 110, 4); display.setTextSize(3); drawCenteredText("Tap to Explore", 200, 3); } while(display.nextPage()); delay(120); } void drawPage2() { display.firstPage(); do { display.fillScreen(GxEPD_WHITE); display.setTextSize(3); drawCenteredText("Hello stranger!", 40, 3); display.setTextSize(2); String para = "Thanks for your willingness to linger here for a moment and explore the object beneath your feet."; drawCenteredParagraph(para, 100, display.width() - 40, 28); display.setTextSize(2); drawCenteredText("-Tap to Continue-", display.height() - 35, 2); } while(display.nextPage()); delay(120); } void drawPage3() { display.firstPage(); do { display.fillScreen(GxEPD_WHITE); display.setTextSize(3); drawCenteredText("This is a StoryStone.", 50, 3); display.setTextSize(2); drawCenteredText("A proof of concept", 110, 2); drawCenteredText("for modular, solar powered,", 140, 2); drawCenteredText("public interest technology", 170, 2); drawCenteredText("that centers the pedestrian.", 200, 2); display.setTextSize(2); drawCenteredText("-Tap to Continue-", display.height() - 35, 2); } while(display.nextPage()); delay(120); } void drawPage4() { display.firstPage(); do { display.fillScreen(GxEPD_WHITE); display.setTextSize(2); String para = "Street bricks and pavers are everywhere. They are a form of modularity in the built environment that rarely invites curiosity or engagement."; drawCenteredParagraph(para, 50, display.width() - 40, 28); display.setTextSize(2); drawCenteredText("But what if they could?", 220, 2); display.setTextSize(2); drawCenteredText("-Tap to Continue-", display.height() - 35, 2); } while(display.nextPage()); delay(120); } void drawPage5() { display.firstPage(); do { display.fillScreen(GxEPD_WHITE); display.setTextSize(2); String para = "What if a brick could be a landmark, a tool for navigating your environment, or a way to learn about the place you're standing?"; drawCenteredParagraph(para, 60, display.width() - 40, 28); display.setTextSize(2); drawCenteredText("-Tap to Continue-", display.height() - 35, 2); } while(display.nextPage()); delay(120); } void drawPage6() { display.firstPage(); do { display.fillScreen(GxEPD_WHITE); display.setTextSize(2); String para = "What if it held directions or collected data,"; drawCenteredParagraph(para, 70, display.width() - 40, 28); display.setTextSize(2); drawCenteredText("or simply offered a moment", 125, 2); drawCenteredText("of delight and discovery?", 155, 2); display.setTextSize(2); drawCenteredText("-Tap to Continue-", display.height() - 35, 2); } while(display.nextPage()); delay(120); } void drawPage7() { display.firstPage(); do { display.fillScreen(GxEPD_WHITE); display.setTextSize(2); String para = "Like an eddy in a river, the StoryStone is an attempt to slow you down, to catch your attention, create meaningful engagement, then return you to the flow of your day."; drawCenteredParagraph(para, 50, display.width() - 40, 28); display.setTextSize(2); drawCenteredText("-Tap to Continue-", display.height() - 35, 2); } while(display.nextPage()); delay(120); } void drawPage8() { display.firstPage(); do { display.fillScreen(GxEPD_WHITE); display.setTextSize(2); drawCenteredText("At a place like MIT...", 50, 2); display.setTextSize(2); String para1 = "Where might a StoryStone or a network of StoryStones belong?"; drawCenteredParagraph(para1, 90, display.width() - 40, 28); String para2 = "What should they say or show? How might they best serve your pedestrian journey?"; drawCenteredParagraph(para2, 150, display.width() - 40, 28); display.setTextSize(2); drawCenteredText("-Tap to Continue-", display.height() - 35, 2); } while(display.nextPage()); delay(120); } void drawPage9() { display.firstPage(); do { display.fillScreen(GxEPD_WHITE); display.setTextSize(2); String para = "A StoryStone could serve as a wayfinding tool - a point of orientation for hyper-local resources that are accessible by foot..."; drawCenteredParagraph(para, 60, display.width() - 40, 28); display.setTextSize(2); drawCenteredText("-Tap to Continue-", display.height() - 35, 2); } while(display.nextPage()); delay(120); } void drawPage10() { display.firstPage(); do { display.fillScreen(GxEPD_WHITE); drawInvertedBitmap(ss_resources_bitmap, 0, 0, display.width(), display.height(), GxEPD_BLACK); } while(display.nextPage()); delay(120); } void drawPage11() { display.firstPage(); do { display.fillScreen(GxEPD_WHITE); display.setTextSize(2); String para = "It could sense your surroundings - air quality, temperature, sound - and provide real-time information about the environment..."; drawCenteredParagraph(para, 60, display.width() - 40, 28); display.setTextSize(2); drawCenteredText("-Tap to Continue-", display.height() - 35, 2); } while(display.nextPage()); delay(120); } void drawPage12() { display.firstPage(); do { display.fillScreen(GxEPD_WHITE); drawInvertedBitmap(ss_enviro_bitmap, 0, 0, display.width(), display.height(), GxEPD_BLACK); } while(display.nextPage()); delay(120); } void drawPage13() { display.firstPage(); do { display.fillScreen(GxEPD_WHITE); display.setTextSize(2); String para = "It could ask questions and gather site-specific data from everyday pedestrian experiences..."; drawCenteredParagraph(para, 70, display.width() - 40, 28); display.setTextSize(2); drawCenteredText("-Tap to Continue-", display.height() - 35, 2); } while(display.nextPage()); delay(120); } void drawPage14() { display.firstPage(); do { display.fillScreen(GxEPD_WHITE); drawInvertedBitmap(ss_survey_bitmap, 0, 0, display.width(), display.height(), GxEPD_BLACK); } while(display.nextPage()); delay(120); } void drawPage15() { display.firstPage(); do { display.fillScreen(GxEPD_WHITE); display.setTextSize(2); drawCenteredText("Want to share", 70, 3); drawCenteredText("your thoughts?", 100, 3); display.setTextSize(2); drawCenteredText("The next page opens", 145, 3); drawCenteredText("the StoryStone portal.", 175, 3); drawCenteredText("-Tap to Continue-", display.height() - 35, 2); } while(display.nextPage()); delay(120); } void drawPage16() { display.firstPage(); do { display.fillScreen(GxEPD_WHITE); display.setTextSize(2); String para = "Open the WiFi settings on your phone and connect to the \"StoryStone.\" Then scan the QR code on the next page."; drawCenteredParagraph(para, 50, display.width() - 40, 28); display.setTextSize(2); drawCenteredText("(It may take a few tries!)", 165, 2); display.setTextSize(2); drawCenteredText("-Tap to Continue-", display.height() - 35, 2); } while(display.nextPage()); delay(120); } void drawPage17() { const int SRC_W = 200, SRC_H = 200; const int DST_W = 180, DST_H = 180; int bx = (display.width() - DST_W) / 2; int by = 70; display.firstPage(); do { display.fillScreen(GxEPD_WHITE); display.setTextSize(2); drawCenteredText("Scan QR or visit:", 15, 2); drawCenteredText("http://192.168.4.1/", 40, 2); drawScaledBitmap(ss_qr_bitmap, SRC_W, SRC_H, bx, by, DST_W, DST_H); drawCenteredText("-Tap to Continue-", display.height() - 20, 2); } while(display.nextPage()); delay(120); } void drawPage18() { display.firstPage(); do { display.fillScreen(GxEPD_WHITE); display.setTextSize(2); String para = "If you wish to collaborate on this project, please feel free to email storystone.project@gmail.com."; drawCenteredParagraph(para, 60, display.width() - 40, 28); display.setTextSize(2); drawCenteredText("Thanks for stopping by!", 210, 2); display.setTextSize(2); drawCenteredText("-Tap to Return to Homepage-", display.height() - 35, 2); } while(display.nextPage()); delay(120); } void drawCurrentPage() { switch(currentPage) { case 1: drawPage1(); break; case 2: drawPage2(); break; case 3: drawPage3(); break; case 4: drawPage4(); break; case 5: drawPage5(); break; case 6: drawPage6(); break; case 7: drawPage7(); break; case 8: drawPage8(); break; case 9: drawPage9(); break; case 10: drawPage10(); break; case 11: drawPage11(); break; case 12: drawPage12(); break; case 13: drawPage13(); break; case 14: drawPage14(); break; case 15: drawPage15(); break; case 16: drawPage16(); break; case 17: drawPage17(); break; case 18: drawPage18(); break; } } // ===== AP control ===== void ensureRoutes() { server.on("/", [](){ String html; html.reserve(8192); html = F(""); html += F(""); html += F(""); html += F("

StoryStone Portal

"); html += F("

Share your thoughts about the StoryStone project or what might improve your pedestrian experiences on campus and elsewhere. (max 200 words):

"); html += F("
"); html += F(""); html += F("
0/200 characters | 0 words
"); html += F("
"); html += F("

Recent Stories

"); int count = 0; for(int i = stories.size() - 1; i >= 0 && count < 10; i--, count++) { html += "

" + htmlEscape(stories[i].text) + "

"; } html += F(""); server.send(200, "text/html", html); }); server.on("/submit", HTTP_POST, [](){ if(server.hasArg("story")) { String story = server.arg("story"); story.trim(); story.replace("<", "<"); story.replace(">", ">"); int wordCount = 0; bool inWord = false; for (size_t i = 0; i < story.length(); i++) { if (isspace(story[i])) { inWord = false; } else if (!inWord) { wordCount++; inWord = true; } } if (story.length() < MIN_STORY_LENGTH || story.length() > MAX_STORY_LENGTH) { server.send(400, "text/plain", "Story must be 1-200 characters"); return; } if (wordCount > 200) { server.send(400, "text/plain", "Story must be 200 words or less"); return; } appendStory("Public", story); String html = F(""); html += F(""); html += F(""); html += F("

Thank you!

Your story has been saved.

Redirecting...

"); server.send(200, "text/html", html); } else server.send(400, "text/plain", "Missing story"); }); server.on("/download", [](){ if (!server.authenticate("admin", "storystone")) { return server.requestAuthentication(); } File f = LittleFS.open(DB_PATH, "r"); if (!f) { server.send(404, "text/plain", "No data found"); return; } server.streamFile(f, "application/json"); f.close(); }); server.on("/admin", [](){ if (!server.authenticate("admin", "storystone")) { return server.requestAuthentication(); } String html = F(""); html += F(""); html += F("

Admin Panel

"); html += F("
"); html += "

Total stories: " + String(stories.size()) + "/" + String(MAX_STORIES) + "

"; File f = LittleFS.open(DB_PATH, "r"); if (f) { html += "

Database size: " + String(f.size()) + " bytes

"; f.close(); } html += F("
"); if (stories.size() > 800) { html += F("
Warning: Storage is over 80% full. Consider downloading and clearing old stories.
"); } html += F("Download All Stories (JSON)
"); html += F("View All Stories

"); html += F("
"); html += F("
"); html += F("
Back to Portal"); html += F(""); server.send(200, "text/html", html); }); server.on("/view_all", [](){ if (!server.authenticate("admin", "storystone")) { return server.requestAuthentication(); } String html = F(""); html += F(""); html += F("

All Stories

"); html += "

Total: " + String(stories.size()) + " stories

"; html += F("← Back to Admin
"); for(int i = stories.size() - 1; i >= 0; i--) { html += "
"; html += "

" + htmlEscape(stories[i].text) + "

"; html += ""; html += "
"; } html += F(""); server.send(200, "text/html", html); }); server.on("/admin_clear", HTTP_POST, [](){ if (!server.authenticate("admin", "storystone")) { return server.requestAuthentication(); } stories.clear(); saveDB(); String html = F(""); html += F(""); html += F(""); html += F("

Success

All stories cleared.

Redirecting...

"); server.send(200, "text/html", html); }); } void startAPIfNeeded() { if (apActive) return; WiFi.softAP(AP_SSID); ensureRoutes(); server.begin(); apActive = true; Serial.println("AP started"); } void stopAPIfActive() { if (!apActive) return; server.stop(); WiFi.softAPdisconnect(true); apActive = false; Serial.println("AP stopped"); } void advancePage() { int oldPage = currentPage; currentPage++; if (currentPage > TOTAL_PAGES) currentPage = 1; Serial.print("ADVANCE: Page "); Serial.print(oldPage); Serial.print(" -> Page "); Serial.println(currentPage); if (currentPage >= 15 && currentPage <= 18) startAPIfNeeded(); else stopAPIfActive(); drawCurrentPage(); stateStartTime = millis(); lastPageAdvanceTime = millis(); } // ===== Main loop ===== void loop() { unsigned long now = millis(); sensors_event_t ev; accel.getEvent(&ev); float movement = abs(ev.acceleration.x - lastX) + abs(ev.acceleration.y - lastY) + abs(ev.acceleration.z - lastZ); lastX = ev.acceleration.x; lastY = ev.acceleration.y; lastZ = ev.acceleration.z; // Use different thresholds for homepage vs other pages float currentThreshold = (currentPage == 1) ? HOMEPAGE_THRESHOLD : MOVEMENT_THRESHOLD; if (movement > currentThreshold) { if (now - lastRawTap > RAW_DEBOUNCE_MS) { lastRawTap = now; Serial.print("TAP detected (movement: "); Serial.print(movement); Serial.print(") at "); Serial.print(now); Serial.print(" | Page: "); Serial.println(currentPage); // Determine which cooldown to use unsigned long requiredCooldown = (currentPage == 2) ? HOMEPAGE_ADVANCE_COOLDOWN_MS : PAGE_ADVANCE_COOLDOWN_MS; unsigned long timeSinceLastAdvance = now - lastPageAdvanceTime; if (timeSinceLastAdvance > requiredCooldown) { Serial.printf("Advancing from page %d (cooldown satisfied: %lu ms)\n", currentPage, timeSinceLastAdvance); advancePage(); // Clear accelerometer buffer after advancing if (currentPage == 2) { delay(100); for (int i = 0; i < 5; i++) { sensors_event_t clearEv; accel.getEvent(&clearEv); lastX = clearEv.acceleration.x; lastY = clearEv.acceleration.y; lastZ = clearEv.acceleration.z; delay(20); } } } else { Serial.printf("Cooldown active on page %d, ignoring tap (%lu ms remaining)\n", currentPage, requiredCooldown - timeSinceLastAdvance); } } } // Timeout check - return to homepage after 2 minutes of inactivity if (currentPage != 1 && stateStartTime > 0) { unsigned long timeDiff = now - stateStartTime; if (timeDiff < 0x80000000UL && timeDiff > TIMEOUT_MS) { Serial.println("TIMEOUT - returning to page 1"); currentPage = 1; stopAPIfActive(); drawCurrentPage(); stateStartTime = 0; } } if (apActive) server.handleClient(); delay(30); } // ===== Setup ===== void setup() { Serial.begin(115200); pinMode(LED_PIN, INPUT); SPI.begin(7, -1, 9, EPD_CS); display.init(115200, true, 2, false); display.setRotation(0); display.setTextColor(GxEPD_BLACK); display.setFullWindow(); I2CBus.begin(5,6); if (!accel.begin(0x1D)) { Serial.println("ADXL343 not detected!"); while(1) delay(1000); } sensors_event_t ev; accel.getEvent(&ev); lastX = ev.acceleration.x; lastY = ev.acceleration.y; lastZ = ev.acceleration.z; if (!LittleFS.begin(true)) { Serial.println("LittleFS mount failed"); } loadDB(); currentPage = 1; drawCurrentPage(); stateStartTime = 0; Serial.println("Setup complete - Single tap navigation active"); }