/* * ESP32 Pendulum Clock Accuracy Monitor (PoC) * * Measures pendulum swing timing via a light barrier (GPIO interrupt), * correlates deviations with temperature, pressure, and humidity (BME280), * stores MINUTE-AGGREGATED data on LittleFS, and serves a web dashboard. * * Each minute, the accumulated crossings are summarized (avg/min/max deviation, * count, environment) into one record → ~1.4 MB ≈ 18k records ≈ ~12 days. */ #include #include #include #include #include #include #include #include #include #include #include // ── Configuration ──────────────────────────────────────────────────────────── // WiFi – adjust to your network static const char *WIFI_SSID = "Meine SSID"; static const char *WIFI_PASSWORD = "Mein Passwort"; // NTP static const char *NTP_SERVER = "pool.ntp.org"; // ntp1.ptb.de static const long GMT_OFFSET = 3600; // CET = UTC+1 static const int DST_OFFSET = 3600; // CEST summer +1 // Light-barrier input (active-LOW with internal pull-up) static const gpio_num_t LIGHT_BARRIER_PIN = GPIO_NUM_4; // BME280/BMP280 I2C (default SDA=21, SCL=22 on most ESP32 boards) // Address is auto-detected (0x76 or 0x77) // Expected pendulum half-period in ms (1 s pendulum → ~500 ms per crossing) static const uint32_t NOMINAL_HALF_PERIOD_MS = 500; // Debounce time for light barrier in µs static const uint32_t DEBOUNCE_US = 50000; // 50 ms // Aggregation interval in seconds static const uint32_t AGGREGATION_INTERVAL_S = 60; // Data-log file on LittleFS static const char *DATA_FILE = "/pendulum_log.csv"; // Maximum entries kept in RAM ring buffer (also max served via API) static const size_t MAX_ENTRIES = 1000; // Environment sampling interval (ms) static const unsigned long ENV_INTERVAL_MS = 10000; // 10 s // ── Data types ─────────────────────────────────────────────────────────────── // One-minute aggregated record struct AggregatedEntry { time_t epoch; // start of the minute (UTC) uint32_t count; // number of crossings in this minute int64_t avgDeviation_us; // mean deviation from nominal (µs) int64_t minDeviation_us; // min deviation in minute (µs) int64_t maxDeviation_us; // max deviation in minute (µs) float temperature; // avg °C during minute float pressure; // avg hPa during minute float humidity; // avg %RH during minute }; // Accumulator for current minute (not persisted) struct MinuteAccumulator { time_t minuteEpoch; // truncated to minute boundary uint32_t count; int64_t sumDeviation; int64_t minDeviation; int64_t maxDeviation; float sumTemp; float sumPressure; float sumHumidity; uint32_t envSamples; // number of env readings accumulated void reset(time_t epoch) { minuteEpoch = epoch; count = 0; sumDeviation = 0; minDeviation = INT64_MAX; maxDeviation = INT64_MIN; sumTemp = 0; sumPressure = 0; sumHumidity = 0; envSamples = 0; } void addCrossing(int64_t deviation_us, float temp, float press, float hum) { count++; sumDeviation += deviation_us; if (deviation_us < minDeviation) minDeviation = deviation_us; if (deviation_us > maxDeviation) maxDeviation = deviation_us; sumTemp += temp; sumPressure += press; sumHumidity += hum; envSamples++; } AggregatedEntry finalize() const { AggregatedEntry e; e.epoch = minuteEpoch; e.count = count; e.avgDeviation_us = count > 0 ? sumDeviation / (int64_t)count : 0; e.minDeviation_us = count > 0 ? minDeviation : 0; e.maxDeviation_us = count > 0 ? maxDeviation : 0; e.temperature = envSamples > 0 ? sumTemp / envSamples : NAN; e.pressure = envSamples > 0 ? sumPressure / envSamples : NAN; e.humidity = envSamples > 0 ? sumHumidity / envSamples : NAN; return e; } }; // ── Sensor type detection ───────────────────────────────────────────────────── enum SensorType { SENSOR_NONE, SENSOR_BME280, SENSOR_BMP280 }; SensorType classifyChipId(uint8_t id) { if (id == 0x60) return SENSOR_BME280; // BME280 if (id == 0x58 || id == 0x56 || id == 0x57) return SENSOR_BMP280; // BMP280 return SENSOR_NONE; } const char *sensorTypeName(SensorType t) { switch (t) { case SENSOR_BME280: return "BME280"; case SENSOR_BMP280: return "BMP280"; default: return "NONE"; } } // Scan I2C bus and print all responding addresses (diagnostic) void i2cScan() { Serial.println("I2C bus scan:"); uint8_t found = 0; for (uint8_t addr = 1; addr < 127; addr++) { Wire.beginTransmission(addr); uint8_t err = Wire.endTransmission(); if (err == 0) { Serial.printf(" Device found at 0x%02X\n", addr); found++; } else if (err == 5) { // timeout – bus may be stuck; abort scan early Serial.printf(" Timeout at 0x%02X – bus may be stuck, aborting scan.\n", addr); break; } } if (found == 0) { Serial.println(" No I2C devices found! Check SDA/SCL wiring and pull-ups."); } else { Serial.printf(" %u device(s) found.\n", found); } } // ── Globals ────────────────────────────────────────────────────────────────── Adafruit_BME280 bme; Adafruit_BMP280 bmp; SensorType detectedSensor = SENSOR_NONE; uint8_t detectedAddr = 0; AsyncWebServer server(80); // Ring buffer for aggregated minute entries AggregatedEntry entries[MAX_ENTRIES]; size_t entryCount = 0; size_t entryHead = 0; // next write position // Current minute accumulator MinuteAccumulator accumulator; bool accumulatorActive = false; // ISR shared state (volatile) volatile int64_t lastCrossingUs = 0; volatile int64_t pendingInterval = 0; // set by ISR, consumed by loop volatile bool newCrossing = false; // Environment cache float envTemp = NAN; float envPressure = NAN; float envHumidity = NAN; unsigned long lastEnvRead = 0; // Global stats (across all time) uint32_t totalCrossings = 0; uint32_t totalMinutes = 0; int64_t globalSumDev = 0; // sum of |deviation| across all crossings int64_t globalMaxDev = 0; bool ntpSynced = false; // ── ISR ────────────────────────────────────────────────────────────────────── void IRAM_ATTR lightBarrierISR() { int64_t now = esp_timer_get_time(); // µs since boot, monotonic if (lastCrossingUs != 0) { int64_t diff = now - lastCrossingUs; if (diff > (int64_t)DEBOUNCE_US) { pendingInterval = diff; newCrossing = true; lastCrossingUs = now; } } else { lastCrossingUs = now; } } // ── Helpers ────────────────────────────────────────────────────────────────── // Truncate epoch to minute boundary time_t minuteOf(time_t t) { return t - (t % AGGREGATION_INTERVAL_S); } void connectWiFi() { Serial.printf("Connecting to WiFi '%s'...\n", WIFI_SSID); WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASSWORD); int attempts = 0; while (WiFi.status() != WL_CONNECTED && attempts < 40) { delay(500); Serial.print("."); attempts++; } if (WiFi.status() == WL_CONNECTED) { Serial.printf("\nConnected! IP: %s\n", WiFi.localIP().toString().c_str()); } else { Serial.println("\nWiFi connection failed – continuing offline."); } } void syncNTP() { configTime(GMT_OFFSET, DST_OFFSET, NTP_SERVER); Serial.print("Waiting for NTP sync"); struct tm ti; int tries = 0; while (!getLocalTime(&ti) && tries < 20) { delay(500); Serial.print("."); tries++; } if (tries < 20) { ntpSynced = true; Serial.printf("\nNTP synced: %04d-%02d-%02d %02d:%02d:%02d\n", ti.tm_year + 1900, ti.tm_mon + 1, ti.tm_mday, ti.tm_hour, ti.tm_min, ti.tm_sec); } else { Serial.println("\nNTP sync failed – timestamps may be inaccurate."); } } bool initSensor() { // ── Start I2C with timeout so probes don't hang ────────────────── Wire.begin(); Wire.setTimeOut(50); // 50 ms timeout per I2C transaction delay(100); // let bus and sensor stabilize // ── Read chip ID to decide which library to use ────────────────── static const uint8_t addrs[] = { 0x76, 0x77 }; for (uint8_t addr : addrs) { Wire.beginTransmission(addr); Wire.write(0xD0); // chip ID register if (Wire.endTransmission() != 0) continue; if (Wire.requestFrom(addr, (uint8_t)1) != 1) continue; uint8_t chipId = Wire.read(); SensorType type = classifyChipId(chipId); Serial.printf("I2C 0x%02X: chip ID 0x%02X -> %s\n", addr, chipId, sensorTypeName(type)); if (type == SENSOR_BME280) { if (bme.begin(addr, &Wire)) { bme.setSampling(Adafruit_BME280::MODE_FORCED, Adafruit_BME280::SAMPLING_X1, Adafruit_BME280::SAMPLING_X1, Adafruit_BME280::SAMPLING_X1, Adafruit_BME280::FILTER_OFF, Adafruit_BME280::STANDBY_MS_1000); detectedSensor = SENSOR_BME280; detectedAddr = addr; Serial.println("BME280 initialized (temp + pressure + humidity)."); return true; } Serial.println("BME280 library init failed!"); } else if (type == SENSOR_BMP280) { if (bmp.begin(addr, chipId)) { bmp.setSampling(Adafruit_BMP280::MODE_FORCED, Adafruit_BMP280::SAMPLING_X1, Adafruit_BMP280::SAMPLING_X1, Adafruit_BMP280::FILTER_OFF, Adafruit_BMP280::STANDBY_MS_1000); detectedSensor = SENSOR_BMP280; detectedAddr = addr; Serial.println("BMP280 initialized (temp + pressure, no humidity)."); return true; } Serial.println("BMP280 library init failed!"); } } Serial.println("No BME280/BMP280 found! Running I2C bus scan for diagnostics..."); i2cScan(); return false; } void readEnvironment() { if (detectedSensor == SENSOR_NONE) return; if (detectedSensor == SENSOR_BME280) { bme.takeForcedMeasurement(); envTemp = bme.readTemperature(); envPressure = bme.readPressure() / 100.0F; envHumidity = bme.readHumidity(); } else { bmp.takeForcedMeasurement(); envTemp = bmp.readTemperature(); envPressure = bmp.readPressure() / 100.0F; envHumidity = NAN; } } // ── LittleFS persistence ──────────────────────────────────────────────────── // CSV header (not written to file, but used for download context) // epoch,count,avgDev_us,minDev_us,maxDev_us,temp,pressure,humidity void appendToLog(const AggregatedEntry &e) { File f = LittleFS.open(DATA_FILE, FILE_APPEND); if (!f) { Serial.println("Failed to open log file for append."); return; } f.printf("%ld,%u,%lld,%lld,%lld,%.2f,%.2f,%.2f\n", (long)e.epoch, e.count, (long long)e.avgDeviation_us, (long long)e.minDeviation_us, (long long)e.maxDeviation_us, e.temperature, e.pressure, e.humidity); f.close(); } size_t loadLog() { if (!LittleFS.exists(DATA_FILE)) return 0; File f = LittleFS.open(DATA_FILE, FILE_READ); if (!f) return 0; size_t count = 0; char line[160]; while (f.available()) { size_t len = f.readBytesUntil('\n', line, sizeof(line) - 1); if (len == 0) continue; line[len] = '\0'; AggregatedEntry e; long ep; unsigned int cnt; long long avgD, minD, maxD; if (sscanf(line, "%ld,%u,%lld,%lld,%lld,%f,%f,%f", &ep, &cnt, &avgD, &minD, &maxD, &e.temperature, &e.pressure, &e.humidity) == 8) { e.epoch = (time_t)ep; e.count = cnt; e.avgDeviation_us = avgD; e.minDeviation_us = minD; e.maxDeviation_us = maxD; entries[entryHead] = e; entryHead = (entryHead + 1) % MAX_ENTRIES; if (entryCount < MAX_ENTRIES) entryCount++; // Rebuild global stats totalCrossings += cnt; totalMinutes++; globalSumDev += (int64_t)abs((int)avgD) * cnt; if (abs((int)maxD) > abs((int)globalMaxDev)) globalMaxDev = maxD; count++; } } f.close(); Serial.printf("Loaded %u aggregated entries from flash.\n", count); return count; } // ── Flush accumulator → aggregated entry ───────────────────────────────────── void flushAccumulator() { if (!accumulatorActive || accumulator.count == 0) return; AggregatedEntry e = accumulator.finalize(); // Store in ring buffer entries[entryHead] = e; entryHead = (entryHead + 1) % MAX_ENTRIES; if (entryCount < MAX_ENTRIES) entryCount++; totalMinutes++; globalSumDev += (int64_t)abs((int)e.avgDeviation_us) * e.count; if (abs((int)e.maxDeviation_us) > abs((int)globalMaxDev)) { globalMaxDev = e.maxDeviation_us; } // Persist appendToLog(e); Serial.printf("Minute aggregated | %u crossings | avgDev=%+lld µs | " "min=%+lld max=%+lld | T=%.1f°C P=%.1f hPa H=%.1f%%\n", e.count, (long long)e.avgDeviation_us, (long long)e.minDeviation_us, (long long)e.maxDeviation_us, e.temperature, e.pressure, e.humidity); accumulatorActive = false; } // ── Web server ─────────────────────────────────────────────────────────────── extern const char INDEX_HTML[]; void setupWebServer() { // Serve dashboard server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) { request->send(200, "text/html", INDEX_HTML); }); // JSON API: current status + last N aggregated entries server.on("/api/status", HTTP_GET, [](AsyncWebServerRequest *request) { JsonDocument doc; doc["uptimeMs"] = millis(); doc["ntpSynced"] = ntpSynced; doc["totalCrossings"] = totalCrossings; doc["totalMinutes"] = totalMinutes; doc["entriesInBuffer"] = entryCount; doc["sensorType"] = sensorTypeName(detectedSensor); doc["hasHumidity"] = (detectedSensor == SENSOR_BME280); // Current environment JsonObject env = doc["environment"].to(); env["temperature"] = serialized(String(envTemp, 2)); env["pressure"] = serialized(String(envPressure, 2)); if (detectedSensor == SENSOR_BME280) { env["humidity"] = serialized(String(envHumidity, 2)); } // Current accumulator (live minute) JsonObject live = doc["currentMinute"].to(); if (accumulatorActive && accumulator.count > 0) { live["crossings"] = accumulator.count; live["avgDevUs"] = accumulator.sumDeviation / (int64_t)accumulator.count; live["minDevUs"] = accumulator.minDeviation; live["maxDevUs"] = accumulator.maxDeviation; } // Stats JsonObject stats = doc["stats"].to(); if (totalCrossings > 0) { stats["avgDeviationUs"] = (double)globalSumDev / totalCrossings; stats["maxDeviationUs"] = globalMaxDev; } // Last entries (most recent first, max 200 for API response) JsonArray arr = doc["entries"].to(); size_t toSend = min(entryCount, (size_t)200); for (size_t i = 0; i < toSend; i++) { size_t idx = (entryHead + MAX_ENTRIES - 1 - i) % MAX_ENTRIES; JsonObject o = arr.add(); o["epoch"] = entries[idx].epoch; o["count"] = entries[idx].count; o["avgDevUs"] = entries[idx].avgDeviation_us; o["minDevUs"] = entries[idx].minDeviation_us; o["maxDevUs"] = entries[idx].maxDeviation_us; o["temp"] = serialized(String(entries[idx].temperature, 2)); o["pressure"] = serialized(String(entries[idx].pressure, 2)); if (detectedSensor == SENSOR_BME280) { o["humidity"] = serialized(String(entries[idx].humidity, 2)); } } String json; serializeJson(doc, json); request->send(200, "application/json", json); }); // Download CSV server.on("/api/download", HTTP_GET, [](AsyncWebServerRequest *request) { if (!LittleFS.exists(DATA_FILE)) { request->send(404, "text/plain", "No data yet."); return; } // Stream file with CSV header prepended AsyncResponseStream *response = request->beginResponseStream("text/csv"); response->addHeader("Content-Disposition", "attachment; filename=\"pendulum_log.csv\""); response->print("epoch,count,avgDev_us,minDev_us,maxDev_us,temp_C,pressure_hPa,humidity_pct\n"); File f = LittleFS.open(DATA_FILE, FILE_READ); if (f) { uint8_t buf[256]; while (f.available()) { size_t n = f.read(buf, sizeof(buf)); response->write(buf, n); } f.close(); } request->send(response); }); // Clear log server.on("/api/clear", HTTP_POST, [](AsyncWebServerRequest *request) { LittleFS.remove(DATA_FILE); entryCount = 0; entryHead = 0; totalCrossings = 0; totalMinutes = 0; globalSumDev = 0; globalMaxDev = 0; accumulatorActive = false; request->send(200, "application/json", "{\"ok\":true}"); }); server.begin(); Serial.println("Web server started on port 80."); } // ── Arduino entry points ───────────────────────────────────────────────────── void setup() { Serial.begin(115200); delay(500); Serial.println("\n=== Pendulum Clock Accuracy Monitor (aggregated) ===\n"); // Light barrier pin – interrupt on falling edge (beam broken) pinMode(LIGHT_BARRIER_PIN, INPUT_PULLUP); attachInterrupt(digitalPinToInterrupt(LIGHT_BARRIER_PIN), lightBarrierISR, FALLING); // Filesystem if (!LittleFS.begin(true)) { Serial.println("LittleFS mount failed!"); } else { Serial.println("LittleFS mounted."); loadLog(); } // BME280 / BMP280 (auto-detect) initSensor(); if (detectedSensor != SENSOR_NONE) { readEnvironment(); } // Network connectWiFi(); if (WiFi.status() == WL_CONNECTED) { syncNTP(); setupWebServer(); } } void loop() { // ── Handle new pendulum crossing ───────────────────────────────────── if (newCrossing) { noInterrupts(); int64_t interval = pendingInterval; newCrossing = false; interrupts(); int64_t nominalUs = (int64_t)NOMINAL_HALF_PERIOD_MS * 1000; int64_t deviationUs = interval - nominalUs; time_t now; time(&now); time_t curMinute = minuteOf(now); // If accumulator is for a different minute, flush and start new if (!accumulatorActive || curMinute != accumulator.minuteEpoch) { flushAccumulator(); accumulator.reset(curMinute); accumulatorActive = true; } accumulator.addCrossing(deviationUs, envTemp, envPressure, envHumidity); totalCrossings++; // Serial output per crossing (can be noisy – useful for debugging) Serial.printf("Crossing #%u | interval=%lld µs | dev=%+lld µs | " "minute has %u crossings\n", totalCrossings, (long long)interval, (long long)deviationUs, accumulator.count); } // ── Check if current minute has elapsed → flush accumulator ────────── if (accumulatorActive) { time_t now; time(&now); if (minuteOf(now) != accumulator.minuteEpoch) { flushAccumulator(); } } // ── Periodic environment reading ───────────────────────────────────── if (millis() - lastEnvRead > ENV_INTERVAL_MS) { lastEnvRead = millis(); readEnvironment(); } // ── WiFi reconnect ─────────────────────────────────────────────────── static unsigned long lastWifiCheck = 0; if (millis() - lastWifiCheck > 30000) { lastWifiCheck = millis(); if (WiFi.status() != WL_CONNECTED) { Serial.println("WiFi lost – reconnecting..."); WiFi.reconnect(); } } } // ── Embedded HTML Dashboard ────────────────────────────────────────────────── const char INDEX_HTML[] PROGMEM = R"rawliteral( Pendel-Uhr Monitor

⏱ Pendel-Uhr Genauigkeitsmonitor

Durchgänge

-
-

Ø Abweichung

-

Max Abweichung

-

Aktuelle Minute

-
-

NTP

-

Temperatur

-

Luftdruck

-

Luftfeuchte

-
MinuteCrossingsØ Abw. (ms) Min (ms)Max (ms) Temp °ChPa%RH
CSV Download
Lade...
)rawliteral";