#include #include #include #include #include #include #include #include #include #include "ss_qr_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; uint64_t epochCounter = 0; // ===== Page navigation ===== int currentPage = 1; const int TOTAL_PAGES = 10; unsigned long stateStartTime = 0; const unsigned long TIMEOUT_MS = 180000UL; // 3 minutes // ===== Accelerometer / tap detection ===== float lastX = 0, lastY = 0, lastZ = 0; const float MOVEMENT_THRESHOLD = 6.0f; unsigned long lastRawTap = 0; const unsigned long RAW_DEBOUNCE_MS = 80UL; const unsigned long DOUBLE_TAP_MS = 600UL; const unsigned long PAGE_ADVANCE_COOLDOWN_MS = 2000UL; // 2 second cooldown after page advance unsigned long lastPageAdvanceTime = 0; unsigned long firstTapTime = 0; int tapBufferCount = 0; // ===== Utility ===== uint64_t nowEpoch() { return millis() / 1000ULL; } uint64_t nextId() { return (nowEpoch() << 16) | (epochCounter++ & 0xFFFFULL); } // ===== 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(16384); 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() { DynamicJsonDocument doc(32768); 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) { Story s; s.id = nextId(); s.type = type; s.text = text; s.ts = nowEpoch(); stories.push_back(s); while (stories.size() > MAX_STORIES) stories.erase(stories.begin()); 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 drawParagraph(const String &text, int startX, int startY, int maxWidth, int lineHeight) { String word = ""; int x = startX; int y = startY; int16_t tbx,tby; uint16_t tbw,tbh; for (size_t i = 0; i <= text.length(); ++i) { char c = (i < text.length()) ? text[i] : ' '; if (c == ' ' || c == '\n' || i == text.length()) { display.getTextBounds(word.c_str(),0,0,&tbx,&tby,&tbw,&tbh); if (x + tbw > startX + maxWidth) { x = startX; y += lineHeight; } display.setCursor(x, y); display.print(word); x += tbw + 4; if (c == '\n') { x = startX; y += lineHeight; } word = ""; } else word += c; } } // ===== EPD helpers (QR scaling) ===== 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); } } } // ===== 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("Double-tap to Explore", 180, 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 to explore the object beneath your feet."; drawParagraph(para, 20, 100, display.width() - 40, 30); drawCenteredText("-Tap to Continue-", display.height() - 35, 2); } while(display.nextPage()); delay(120); } void drawPage3() { display.firstPage(); do { display.fillScreen(GxEPD_WHITE); display.setTextSize(2); String para = "This device, named a StoryStone, was born out of an interest in the humble, modular, and durable street brick. Unless a mason, or perhaps an urban planner, these ordinary objects rarely spark a moment of intrigue. But what if they could become a site of engagement?"; drawParagraph(para, 20, 40, display.width() - 40, 22); 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 = "...What if these building blocks could serve as landmark, a point of orientation, or a way to help you to learn about the landscape that surrounds you? What if they could be practical devices that provide directions, or gather and share environmental health quality data?"; drawParagraph(para, 20, 40, display.width() - 40, 22); 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 they could be built environment easter egg, providing an opportunity for delight or discovery? What if they could rehumanize the built environment by collecting and sharing stories, or by prompting you to linger, gather, and engage with others?"; drawParagraph(para, 20, 40, display.width() - 40, 22); 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 = "Like an eddy on a river, the StoryStone is an attempt capture your attention, slow down the pedestrian pace, and inspire curiosity before sending back into the flow of your day. It's also attempt to question the nature, and purpose, of public spaces."; drawParagraph(para, 20, 40, display.width() - 40, 22); 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 = "At a place like MIT, where might a StoryStone, or network of StoryStones, belong? What sorts of prompts, information, and interfaces could they contain to spark and sustain interest? Where else might this tool be used and how could it serve other communities?"; drawParagraph(para, 20, 40, display.width() - 40, 22); 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); String para = "In order to get your reflections on the StoryStone, the following page provides a QR code that will take to the StoryStone's WiFi interface. To connect, first open the WiFi settings on your phone and connect to the \"StoryStone\" network. Then, on the next page, scan the QR code which will take you to the StoryStone Portal."; drawParagraph(para, 20, 30, display.width() - 40, 22); drawCenteredText("-Tap to Continue-", display.height() - 35, 2); } while(display.nextPage()); delay(120); } void drawPage9() { 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 = 50; display.firstPage(); do { display.fillScreen(GxEPD_WHITE); display.setTextSize(2); drawCenteredText("Scan QR or visit:", 15, 2); drawCenteredText("http://192.168.4.1/", 35, 2); drawScaledBitmap(ss_qr_bitmap, SRC_W, SRC_H, bx, by, DST_W, DST_H); drawCenteredText("-Tap to Continue-", display.height() - 35, 2); } while(display.nextPage()); delay(120); } void drawPage10() { 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. Thanks for stopping by!"; drawParagraph(para, 20, 80, display.width() - 40, 22); 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; } } // ===== AP control ===== void ensureRoutes() { server.on("/", [](){ String html = ""; html += ""; html += "

StoryStone Portal

"; html += "

Share your thoughts about this place, the StoryStone project, or the MIT campus:

"; html += "
"; html += "
"; html += "
"; html += "

Recent Stories

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

" + stories[i].text + "

"; } html += ""; server.send(200, "text/html", html); }); server.on("/submit", HTTP_POST, [](){ if(server.hasArg("story")) { String story = server.arg("story"); appendStory("Public", story); String html = ""; html += ""; html += ""; html += "

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", [](){ 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_clear_xyz", [](){ if (!server.authenticate("admin", "stone2024")) return server.requestAuthentication(); stories.clear(); saveDB(); String html = ""; html += ""; html += "

Success

All data cleared.

"; 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 == 8 || currentPage == 9) startAPIfNeeded(); else stopAPIfActive(); drawCurrentPage(); stateStartTime = millis(); // Reset timer on page change lastPageAdvanceTime = millis(); // Set cooldown timer Serial.print("stateStartTime set to: "); Serial.println(stateStartTime); // Reset tap detection state when changing pages tapBufferCount = 0; firstTapTime = 0; } // ===== 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; if (movement > MOVEMENT_THRESHOLD) { if (now - lastRawTap > RAW_DEBOUNCE_MS) { lastRawTap = now; if (currentPage == 1) { // Double-tap logic for page 1 only if (tapBufferCount == 0) { firstTapTime = now; tapBufferCount = 1; Serial.println("First tap detected on page 1"); } else if (tapBufferCount == 1 && now - firstTapTime <= DOUBLE_TAP_MS) { // Valid double-tap detected Serial.println("Double-tap confirmed! Advancing to page 2"); tapBufferCount = 0; firstTapTime = 0; currentPage = 2; drawCurrentPage(); stateStartTime = now; lastPageAdvanceTime = now; // Set cooldown after advancing from page 1 } else { // First tap expired, this is a new first tap firstTapTime = now; tapBufferCount = 1; Serial.println("First tap expired, new first tap"); } } else { // Single tap advances on all other pages, but check cooldown if (now - lastPageAdvanceTime > PAGE_ADVANCE_COOLDOWN_MS) { Serial.print("Tap detected on page "); Serial.print(currentPage); Serial.println(", advancing..."); advancePage(); } else { Serial.print("Tap ignored - cooldown active ("); Serial.print(now - lastPageAdvanceTime); Serial.println("ms since last advance)"); } } } } // Clear double-tap buffer if timeout exceeded on page 1 if (currentPage == 1 && tapBufferCount == 1 && now - firstTapTime > DOUBLE_TAP_MS) { tapBufferCount = 0; firstTapTime = 0; } // Auto-return after 3 minutes of inactivity // Fix: handle millis() rollover/timing issues properly if (currentPage != 1) { // Only check timeout if current time is greater than start time (normal case) // or if the difference is reasonable even with rollover unsigned long timeDiff = now - stateStartTime; // If timeDiff is huge (like > half of max unsigned long), it's likely underflow, skip the check if (timeDiff < 0x80000000UL && timeDiff > TIMEOUT_MS) { Serial.println("!!!!! TIMEOUT TRIGGERED - RETURNING TO PAGE 1 !!!!!"); Serial.print("Current time: "); Serial.print(now); Serial.print(", stateStartTime: "); Serial.print(stateStartTime); Serial.print(", difference: "); Serial.println(timeDiff); currentPage = 1; stopAPIfActive(); tapBufferCount = 0; firstTapTime = 0; drawCurrentPage(); stateStartTime = now; } } 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 = millis(); Serial.println("Setup complete."); }