// Seebscribe - XIAO ESP32-C3 + AD8232 embedded Wi-Fi ECG dashboard // // This fork removes the computer/Python gateway from the local architecture: // // XIAO ESP32-C3 + AD8232 -> Wi-Fi -> browser dashboard served by the XIAO // // Upload this sketch, open the Serial Monitor at 115200 baud, and copy the // printed dashboard URL. The board serves: // / live dashboard // /events Server-Sent Events ECG stream // /status small JSON status endpoint // // This is a prototyping/art-monitoring service, not a clinical monitor. #include #include #include #include const char* WIFI_SSID = "YOUR_WIFI_NAME"; const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD"; const char* MDNS_NAME = "seebscribe-ecg"; const char* FIRMWARE_VERSION = "0.23-demo"; const char* FIRMWARE_LABEL = "v0.23 direct AD8232 demo"; const int ECG_PIN = A0; const int LO_PLUS = D1; const int LO_MINUS = D2; // XIAO ESP32-C3 BAT+/BAT- are not connected to an ADC internally. // Keep false unless you add an external voltage divider from BAT+ to an ADC pin. // Recommended divider: two equal high-value resistors, so ADC sees battery / 2. const bool BATTERY_MONITOR_ENABLED = false; const int BATTERY_PIN = A1; const float BATTERY_DIVIDER_RATIO = 2.0f; const float BATTERY_EMPTY_V = 3.30f; const float BATTERY_FULL_V = 4.20f; // R peaks are normally shown as upward spikes in this dashboard. // Use 1 for upward peaks, -1 for inverted/downward peaks, or 0 for auto. const int8_t PEAK_POLARITY = 1; const uint16_t HTTP_PORT = 80; const uint16_t SAMPLE_RATE = 250; const uint32_t SAMPLE_INTERVAL_US = 1000000UL / SAMPLE_RATE; const uint8_t SAMPLES_PER_PACKET = 20; const uint8_t FILTER_SIZE = 4; const uint8_t RR_BUFFER_SIZE = 16; const bool DIRECT_AD8232_DEMO = true; WiFiServer httpServer(HTTP_PORT); // Multiple simultaneous dashboard viewers. A single-slot stream made every // new tab/phone steal the connection from the previous viewer, which looked // like random freezing on whoever was watching. const uint8_t MAX_SSE_CLIENTS = 3; WiFiClient sseClients[MAX_SSE_CLIENTS]; uint8_t nextSseSlot = 0; // Append-to-String Print adapter so JSON builders can target a buffer. class StringPrint : public Print { public: String buf; size_t write(uint8_t c) override { buf += (char)c; return 1; } size_t write(const uint8_t* data, size_t len) override { buf.concat((const char*)data, len); return len; } }; uint16_t rawBatch[SAMPLES_PER_PACKET]; uint16_t filteredBatch[SAMPLES_PER_PACKET]; uint16_t pulseBatch[SAMPLES_PER_PACKET]; uint8_t batchCount = 0; uint32_t batchStartIndex = 0; uint16_t filterBuffer[FILTER_SIZE]; uint8_t filterIndex = 0; uint32_t filterSum = 0; uint32_t sampleIndex = 0; uint32_t nextSampleAt = 0; uint32_t lastStatusPrintAt = 0; uint32_t lastMetricsAt = 0; uint32_t lastSseKeepaliveAt = 0; uint32_t lastBatteryAt = 0; float baseline = 2048.0f; float filteredSignal = 0.0f; float pulseIndicator = 0.0f; float noiseLevel = 20.0f; float peakMax = 0.0f; float peakSigned = 0.0f; uint16_t peakValue = 2048; uint32_t peakSample = 0; uint32_t peakStartSample = 0; uint32_t lastPeakSample = 0; uint32_t lastCandidateSample = 0; uint32_t lastPulseSample = 0; bool inPeak = false; int8_t autoPolarity = 0; int8_t polarityScore = 0; float rrBuffer[RR_BUFFER_SIZE]; uint8_t rrCount = 0; uint8_t rrWrite = 0; uint32_t beatCount = 0; uint32_t pulseCount = 0; uint32_t sseDrops = 0; uint32_t rejectedBeats = 0; uint32_t candidateBeats = 0; uint16_t bpm = 0; uint16_t rrMs = 0; uint16_t pulseBpm = 0; uint16_t pulseRrMs = 0; uint8_t confidence = 0; bool stable = false; bool clipped = false; uint8_t clipPercent = 0; uint16_t threshold = 90; uint16_t amplitude = 0; uint16_t signalSpan = 0; String rhythm = "Acquiring"; String quality = "Acquiring"; String hint = "Waiting for peaks."; float batteryVoltage = 0.0f; int8_t batteryPercent = -1; String batteryState = "not wired"; const char DASHBOARD_HTML[] PROGMEM = R"HTML( Seebscribe Embedded ECG

Seebscribe Embedded ECG

v0.23 direct AD8232 demo
connectingInfo
rawfilteredAD8232 pulseR peak
threshold --
0 sampleslast packet --
)HTML"; uint16_t filteredRead(uint16_t raw) { filterSum -= filterBuffer[filterIndex]; filterBuffer[filterIndex] = raw; filterSum += raw; filterIndex = (filterIndex + 1) % FILTER_SIZE; return filterSum / FILTER_SIZE; } float rrAt(uint8_t logicalIndex) { if (logicalIndex >= rrCount) return 0; uint8_t start = (rrWrite + RR_BUFFER_SIZE - rrCount) % RR_BUFFER_SIZE; return rrBuffer[(start + logicalIndex) % RR_BUFFER_SIZE]; } float medianRecentRr(uint8_t n) { if (rrCount == 0) return 0; uint8_t count = min(n, rrCount); float values[RR_BUFFER_SIZE]; for (uint8_t i = 0; i < count; i++) { values[i] = rrAt(rrCount - count + i); } for (uint8_t i = 0; i < count; i++) { for (uint8_t j = i + 1; j < count; j++) { if (values[j] < values[i]) { float tmp = values[i]; values[i] = values[j]; values[j] = tmp; } } } return values[count / 2]; } float rrAverage(uint8_t n) { uint8_t count = min(n, rrCount); if (!count) return 0; float total = 0; for (uint8_t i = rrCount - count; i < rrCount; i++) total += rrAt(i); return total / count; } float rrStdDev(uint8_t n) { uint8_t count = min(n, rrCount); if (count < 2) return 0; float avg = rrAverage(count); float total = 0; for (uint8_t i = rrCount - count; i < rrCount; i++) { float d = rrAt(i) - avg; total += d * d; } return sqrt(total / count); } float rmssdValue() { uint8_t count = min((uint8_t)10, rrCount); if (count < 4) return 0; float total = 0; uint8_t diffs = 0; for (uint8_t i = rrCount - count + 1; i < rrCount; i++) { float d = rrAt(i) - rrAt(i - 1); total += d * d; diffs++; } return diffs ? sqrt(total / diffs) : 0; } int batteryPercentFromVoltage(float voltage) { if (voltage <= 0.0f) return -1; float pct = (voltage - BATTERY_EMPTY_V) * 100.0f / (BATTERY_FULL_V - BATTERY_EMPTY_V); return constrain((int)round(pct), 0, 100); } void updateBattery() { if (!BATTERY_MONITOR_ENABLED) { batteryVoltage = 0.0f; batteryPercent = -1; batteryState = "not wired"; return; } uint32_t millivolts = 0; for (uint8_t i = 0; i < 12; i++) { millivolts += analogReadMilliVolts(BATTERY_PIN); } float adcVoltage = (millivolts / 12.0f) / 1000.0f; batteryVoltage = adcVoltage * BATTERY_DIVIDER_RATIO; batteryPercent = batteryPercentFromVoltage(batteryVoltage); if (batteryVoltage < 3.45f) batteryState = "low"; else if (batteryVoltage > 4.12f) batteryState = "full"; else batteryState = "battery"; } void addRr(float rr) { rrBuffer[rrWrite] = rr; rrWrite = (rrWrite + 1) % RR_BUFFER_SIZE; if (rrCount < RR_BUFFER_SIZE) rrCount++; } void updateMetricsFromRr() { if (rrCount == 0) { bpm = 0; confidence = 0; stable = false; rhythm = "Acquiring"; quality = "Looking for beats"; return; } float med = medianRecentRr(6); rrMs = (uint16_t)round(med); bpm = rrMs ? (uint16_t)round(60000.0f / rrMs) : 0; uint8_t count = min((uint8_t)8, rrCount); float avg = rrAverage(count); float cv = avg > 0 ? rrStdDev(count) / avg : 1.0f; float rejectRatio = candidateBeats ? (float)rejectedBeats / candidateBeats : 0.0f; int conf = 100 - (int)(cv * 230.0f) - (int)(rejectRatio * 55.0f) - max(0, (int)noiseLevel - 80) / 3; confidence = (uint8_t)constrain(conf, 0, 100); stable = confidence >= 55 && rrCount >= 4; if (rrCount < 3) rhythm = "Acquiring peaks"; else if (!stable) rhythm = "Unstable peaks"; else if (bpm > 100) rhythm = "Tachycardia"; else if (bpm < 60) rhythm = "Bradycardia"; else rhythm = "Live rhythm"; if (clipped) quality = "Clipped/noisy"; else if (stable) quality = cv < 0.10f ? "Good" : "Fair"; else quality = "Tentative"; hint = stable ? "Streaming from XIAO embedded web server." : "Live metrics are tentative; motion/noise affects accuracy."; } int8_t activePeakPolarity() { if (PEAK_POLARITY > 0) return 1; if (PEAK_POLARITY < 0) return -1; return autoPolarity; } void learnPolarity(float signedPeak) { if (PEAK_POLARITY != 0) return; if (signedPeak > 0) polarityScore++; if (signedPeak < 0) polarityScore--; polarityScore = constrain(polarityScore, -4, 4); if (polarityScore >= 2) autoPolarity = 1; if (polarityScore <= -2) autoPolarity = -1; } void sendPulseEvent(); void updatePulseMetricsFromCandidate() { pulseCount++; if (!lastPulseSample) { lastPulseSample = peakSample; pulseRrMs = 0; pulseBpm = 0; return; } float rr = (peakSample - lastPulseSample) * 1000.0f / SAMPLE_RATE; if (rr >= 300 && rr <= 2000) { lastPulseSample = peakSample; pulseRrMs = (uint16_t)round(rr); pulseBpm = pulseRrMs ? (uint16_t)round(60000.0f / pulseRrMs) : 0; rhythm = "AD8232 pulse live"; quality = "Pulse priority"; hint = "Heart animation and quick BPM follow the AD8232 pulse stream."; } else if (rr > 2000) { lastPulseSample = peakSample; pulseRrMs = 0; pulseBpm = 0; } } bool detectBeat(uint16_t filteredValue, float signedSignal) { uint32_t lockout = SAMPLE_RATE * 22 / 100; if (!inPeak && lastCandidateSample && sampleIndex - lastCandidateSample < lockout) { return false; } int8_t polarity = activePeakPolarity(); float signal = polarity == 0 ? fabs(signedSignal) : signedSignal * polarity; float enter = max(24.0f, (float)threshold * 0.82f); float leave = max(12.0f, (float)threshold * 0.42f); if (!inPeak && signal > enter) { inPeak = true; peakMax = signal; peakSigned = signedSignal; peakValue = filteredValue; peakSample = sampleIndex; peakStartSample = sampleIndex; updatePulseMetricsFromCandidate(); sendPulseEvent(); return false; } if (inPeak && signal > peakMax) { peakMax = signal; peakSigned = signedSignal; peakValue = filteredValue; peakSample = sampleIndex; } if (!inPeak || signal > leave) return false; inPeak = false; candidateBeats++; lastCandidateSample = peakSample; float widthMs = (sampleIndex - peakStartSample) * 1000.0f / SAMPLE_RATE; if (widthMs < 6 || widthMs > 260) { rejectedBeats++; hint = "Rejected an unlikely peak width."; return false; } learnPolarity(peakSigned); if (!lastPeakSample) { lastPeakSample = peakSample; rhythm = "Detecting peaks"; hint = "First peak found. Waiting for next interval."; return false; } float rr = (peakSample - lastPeakSample) * 1000.0f / SAMPLE_RATE; if (rr < 280 || rr > 2200) { rejectedBeats++; if (rr > 2200) lastPeakSample = peakSample; hint = "Rejected an impossible RR interval."; return false; } lastPeakSample = peakSample; addRr(rr); beatCount++; updateMetricsFromRr(); return true; } void sampleOnce() { uint16_t value = analogRead(ECG_PIN); sampleIndex++; if (DIRECT_AD8232_DEMO) { static uint16_t minSeenDirect = 4095; static uint16_t maxSeenDirect = 0; static uint16_t spanCounterDirect = 0; bool railDirect = value <= 3 || value >= 4092; static uint8_t clipWindowDirect[250]; static uint8_t clipIndexDirect = 0; static uint16_t clipSumDirect = 0; clipSumDirect -= clipWindowDirect[clipIndexDirect]; clipWindowDirect[clipIndexDirect] = railDirect ? 1 : 0; clipSumDirect += clipWindowDirect[clipIndexDirect]; clipIndexDirect = (clipIndexDirect + 1) % 250; clipPercent = (uint8_t)round(clipSumDirect * 100.0f / 250.0f); clipped = clipPercent >= 18; minSeenDirect = min(minSeenDirect, value); maxSeenDirect = max(maxSeenDirect, value); if (++spanCounterDirect >= SAMPLE_RATE) { amplitude = maxSeenDirect - minSeenDirect; signalSpan = amplitude; minSeenDirect = 4095; maxSeenDirect = 0; spanCounterDirect = 0; } if (batchCount == 0) batchStartIndex = sampleIndex; rawBatch[batchCount] = value; batchCount++; if (batchCount >= SAMPLES_PER_PACKET) { sendSamplesEvent(); batchCount = 0; } return; } uint16_t filteredInput = filteredRead(value); bool rail = value <= 3 || value >= 4092; static uint8_t clipWindow[250]; static uint8_t clipIndex = 0; static uint16_t clipSum = 0; clipSum -= clipWindow[clipIndex]; clipWindow[clipIndex] = rail ? 1 : 0; clipSum += clipWindow[clipIndex]; clipIndex = (clipIndex + 1) % 250; clipPercent = (uint8_t)round(clipSum * 100.0f / 250.0f); clipped = clipPercent >= 18; baseline += (filteredInput - baseline) * 0.004f; float centered = filteredInput - baseline; filteredSignal += (centered - filteredSignal) * 0.22f; int filteredValue = (int)round(baseline + filteredSignal); filteredValue = constrain(filteredValue, 0, 4095); float signal = fabs(filteredSignal); noiseLevel += (signal - noiseLevel) * 0.014f; threshold = (uint16_t)constrain(noiseLevel * 4.2f, 45.0f, 1800.0f); float pulseTarget = signal > max(18.0f, (float)threshold * 0.55f) ? filteredSignal : 0.0f; float pulseAlpha = fabs(pulseTarget) > fabs(pulseIndicator) ? 0.58f : 0.075f; pulseIndicator += (pulseTarget - pulseIndicator) * pulseAlpha; int pulseValue = (int)round(baseline + pulseIndicator); pulseValue = constrain(pulseValue, 0, 4095); static uint16_t minSeen = 4095; static uint16_t maxSeen = 0; static uint16_t spanCounter = 0; minSeen = min(minSeen, value); maxSeen = max(maxSeen, value); if (++spanCounter >= SAMPLE_RATE) { amplitude = maxSeen - minSeen; signalSpan = amplitude; minSeen = 4095; maxSeen = 0; spanCounter = 0; } if (batchCount == 0) batchStartIndex = sampleIndex; rawBatch[batchCount] = value; filteredBatch[batchCount] = (uint16_t)filteredValue; pulseBatch[batchCount] = (uint16_t)pulseValue; batchCount++; bool beat = detectBeat((uint16_t)filteredValue, filteredSignal); if (batchCount >= SAMPLES_PER_PACKET) { sendSamplesEvent(); batchCount = 0; } if (beat) sendBeatEvent(); } void printJsonString(Print& client, const String& value) { client.print('"'); for (uint16_t i = 0; i < value.length(); i++) { char c = value[i]; if (c == '"' || c == '\\') client.print('\\'); client.print(c); } client.print('"'); } uint16_t displayThreshold() { int8_t polarity = activePeakPolarity(); int value = (int)round(baseline + threshold * (polarity < 0 ? -1 : 1)); return (uint16_t)constrain(value, 0, 4095); } void printMetricsJson(Print& client, bool includeType) { float rmssd = rmssdValue(); if (includeType) client.print(F("{\"type\":\"metrics\",")); else client.print('{'); client.print(F("\"ip\":\"")); client.print(WiFi.localIP()); client.print(F("\",\"firmware_version\":\"")); client.print(FIRMWARE_VERSION); client.print(F("\",\"firmware_label\":\"")); client.print(FIRMWARE_LABEL); client.print(F("\",\"sample_rate\":")); client.print(SAMPLE_RATE); client.print(F(",\"rssi\":")); client.print(WiFi.RSSI()); client.print(F(",\"bpm\":")); client.print(bpm); client.print(F(",\"rr\":")); client.print(rrMs); client.print(F(",\"pulse_bpm\":")); client.print(pulseBpm); client.print(F(",\"pulse_rr\":")); client.print(pulseRrMs); client.print(F(",\"rmssd\":")); client.print(rmssd, 1); client.print(F(",\"rmssd_ready\":")); client.print(rrCount >= 4 ? F("true") : F("false")); client.print(F(",\"beat_count\":")); client.print(beatCount); client.print(F(",\"pulse_count\":")); client.print(pulseCount); client.print(F(",\"candidate_beats\":")); client.print(candidateBeats); client.print(F(",\"rejected_beats\":")); client.print(rejectedBeats); client.print(F(",\"confidence\":")); client.print(confidence); client.print(F(",\"stable\":")); client.print(stable ? F("true") : F("false")); client.print(F(",\"rhythm\":")); printJsonString(client, rhythm); client.print(F(",\"quality\":")); printJsonString(client, quality); client.print(F(",\"hint\":")); printJsonString(client, hint); client.print(F(",\"threshold\":")); client.print(displayThreshold()); client.print(F(",\"peak_polarity\":")); client.print(activePeakPolarity()); client.print(F(",\"noise\":")); client.print((uint16_t)round(noiseLevel)); client.print(F(",\"amplitude\":")); client.print(amplitude); client.print(F(",\"signal_span\":")); client.print(signalSpan); client.print(F(",\"clipped\":")); client.print(clipped ? F("true") : F("false")); client.print(F(",\"clip_percent\":")); client.print(clipPercent); client.print(F(",\"battery_voltage\":")); client.print(batteryVoltage, 2); client.print(F(",\"battery_percent\":")); client.print((int)batteryPercent); client.print(F(",\"battery_state\":")); printJsonString(client, batteryState); client.print('}'); } // Send a complete SSE event in one TCP write. If the socket's send buffer // cannot take the whole payload (slow client / tunnel backpressure), the // event is dropped instead of blocking loop() — a blocked write here is what // used to freeze sampling and stall the dashboard. bool sseHasClients() { for (uint8_t i = 0; i < MAX_SSE_CLIENTS; i++) { if (sseClients[i] && sseClients[i].connected()) return true; } return false; } // Write one HTTP chunked-encoding frame to a single client. Chunked framing // is what makes the SSE stream flush immediately through proxies such as the // Cloudflare quick tunnel — without it the edge buffers the whole response // waiting for an EOF that a live stream never sends, so the browser sees // nothing. Each frame is: CRLF CRLF. // Do NOT consult availableForWrite(): on the ESP32 core it reports misleading // values for a healthy socket. Time the write instead — a write that stalls // or comes up short means the socket is wedged, so kill it and let that // browser's EventSource reconnect, without disturbing other viewers. bool sseWriteChunk(WiFiClient& cl, const String& payload) { if (!cl || !cl.connected() || payload.length() == 0) return false; char hdr[12]; int hlen = snprintf(hdr, sizeof(hdr), "%X\r\n", (unsigned)payload.length()); uint32_t startedAt = millis(); bool ok = cl.write((const uint8_t*)hdr, hlen) == (size_t)hlen && cl.write((const uint8_t*)payload.c_str(), payload.length()) == payload.length() && cl.write((const uint8_t*)"\r\n", 2) == 2; if (!ok || millis() - startedAt > 250) { sseDrops++; cl.stop(); return false; } return true; } // Send a complete SSE event to every connected viewer as one chunked frame. void sseBroadcast(const String& payload) { for (uint8_t i = 0; i < MAX_SSE_CLIENTS; i++) { WiFiClient& cl = sseClients[i]; if (!cl || !cl.connected()) continue; sseWriteChunk(cl, payload); } } void sendSamplesEvent() { if (!sseHasClients()) return; String out; out.reserve(DIRECT_AD8232_DEMO ? 320 : 700); out += F("event: samples\ndata: {\"type\":\"samples\",\"sample_rate\":"); out += SAMPLE_RATE; out += F(",\"start_index\":"); out += batchStartIndex; out += F(",\"samples\":["); for (uint8_t i = 0; i < batchCount; i++) { if (i) out += ','; out += rawBatch[i]; } if (!DIRECT_AD8232_DEMO) { out += F("],\"filtered_samples\":["); for (uint8_t i = 0; i < batchCount; i++) { if (i) out += ','; out += filteredBatch[i]; } out += F("],\"pulse_samples\":["); for (uint8_t i = 0; i < batchCount; i++) { if (i) out += ','; out += pulseBatch[i]; } } out += F("],\"threshold\":"); out += displayThreshold(); if (DIRECT_AD8232_DEMO) out += F(",\"direct\":true"); out += F(",\"clipped\":"); out += clipped ? F("true") : F("false"); out += F(",\"lead_off\":false}\n\n"); sseBroadcast(out); } void sendBeatEvent() { if (!sseHasClients()) return; String out; out.reserve(280); out += F("event: beat\ndata: {\"type\":\"beat\",\"sample_index\":"); out += peakSample; out += F(",\"value\":"); out += peakValue; out += F(",\"rr\":"); out += rrMs; out += F(",\"bpm\":"); out += bpm; out += F(",\"pulse_rr\":"); out += pulseRrMs; out += F(",\"pulse_bpm\":"); out += pulseBpm; out += F(",\"beat_count\":"); out += beatCount; out += F(",\"pulse_count\":"); out += pulseCount; out += F(",\"confidence\":"); out += confidence; out += F(",\"stable\":"); out += stable ? F("true") : F("false"); out += F("}\n\n"); sseBroadcast(out); } void sendPulseEvent() { if (!sseHasClients()) return; String out; out.reserve(240); out += F("event: pulse\ndata: {\"type\":\"pulse\",\"sample_index\":"); out += peakSample; out += F(",\"value\":"); out += peakValue; out += F(",\"pulse_rr\":"); out += pulseRrMs; out += F(",\"pulse_bpm\":"); out += pulseBpm; out += F(",\"pulse_count\":"); out += pulseCount; out += F(",\"strength\":"); out += String(peakMax, 1); out += F(",\"threshold\":"); out += displayThreshold(); out += F("}\n\n"); sseBroadcast(out); } void sendMetricsEvent() { if (!sseHasClients()) return; StringPrint sp; sp.buf.reserve(800); sp.buf += F("event: metrics\ndata: "); printMetricsJson(sp, true); sp.buf += F("\n\n"); sseBroadcast(sp.buf); } void sendStatusEvent(WiFiClient& target) { if (!target || !target.connected()) return; StringPrint sp; sp.buf.reserve(700); Print& sseClient = sp; sseClient.print(F("event: status\ndata: ")); sseClient.print(F("{\"type\":\"status\",")); sseClient.print(F("\"ip\":\"")); sseClient.print(WiFi.localIP()); sseClient.print(F("\",\"firmware_version\":\"")); sseClient.print(FIRMWARE_VERSION); sseClient.print(F("\",\"firmware_label\":\"")); sseClient.print(FIRMWARE_LABEL); sseClient.print(F("\",\"sample_rate\":")); sseClient.print(SAMPLE_RATE); sseClient.print(F(",\"pulse_bpm\":")); sseClient.print(pulseBpm); sseClient.print(F(",\"pulse_rr\":")); sseClient.print(pulseRrMs); sseClient.print(F(",\"pulse_count\":")); sseClient.print(pulseCount); sseClient.print(F(",\"rssi\":")); sseClient.print(WiFi.RSSI()); sseClient.print(F(",\"quality\":")); printJsonString(sseClient, quality); sseClient.print(F(",\"rhythm\":")); printJsonString(sseClient, rhythm); sseClient.print(F(",\"hint\":")); printJsonString(sseClient, hint); sseClient.print(F(",\"threshold\":")); sseClient.print(displayThreshold()); sseClient.print(F(",\"peak_polarity\":")); sseClient.print(activePeakPolarity()); sseClient.print(F(",\"amplitude\":")); sseClient.print(amplitude); sseClient.print(F(",\"signal_span\":")); sseClient.print(signalSpan); sseClient.print(F(",\"noise\":")); sseClient.print((uint16_t)round(noiseLevel)); sseClient.print(F(",\"confidence\":")); sseClient.print(confidence); sseClient.print(F(",\"rejected_beats\":")); sseClient.print(rejectedBeats); sseClient.print(F(",\"battery_voltage\":")); sseClient.print(batteryVoltage, 2); sseClient.print(F(",\"battery_percent\":")); sseClient.print((int)batteryPercent); sseClient.print(F(",\"battery_state\":")); printJsonString(sseClient, batteryState); sseClient.print(F("}\n\n")); sseWriteChunk(target, sp.buf); } void sendDashboard(WiFiClient& client) { client.println(F("HTTP/1.1 200 OK")); client.println(F("Content-Type: text/html; charset=utf-8")); client.println(F("Cache-Control: no-store")); client.println(F("Connection: close")); client.println(); client.print(FPSTR(DASHBOARD_HTML)); } void sendInfoPage(WiFiClient& client) { client.println(F("HTTP/1.1 200 OK")); client.println(F("Content-Type: text/html; charset=utf-8")); client.println(F("Cache-Control: no-store")); client.println(F("Connection: close")); client.println(); client.println(F("Seebscribe XIAO Info")); client.println(F("")); client.println(F("")); client.println(F("

Seebscribe XIAO ECG

Embedded Wi-Fi dashboard is running.

")); client.println(F("

Public tunnel

")); client.println(F("

The XIAO cannot launch Cloudflared directly. It has no computer-style shell for running the tunnel process.

")); client.println(F("

Run one of these commands on a Mac, Raspberry Pi, or server on the same Wi-Fi network, then copy the public trycloudflare link that appears. This is only a quick test path; the live stream uses SSE, and quick tunnels can be unreliable for that.

")); client.print(F("

Using mDNS

cloudflared tunnel --url http://")); client.print(MDNS_NAME); client.println(F(".local")); client.print(F("

Using current IP

cloudflared tunnel --url http://")); client.print(WiFi.localIP()); client.println(F("")); client.println(F("

For a future dedicated server version, the XIAO should send data outward to MQTT/HTTP/WebSocket, and the web server should host the public dashboard.

")); client.println(F("")); } void sendStatusJson(WiFiClient& client) { client.println(F("HTTP/1.1 200 OK")); client.println(F("Content-Type: application/json")); client.println(F("Access-Control-Allow-Origin: *")); client.println(F("Cache-Control: no-store")); client.println(F("Connection: close")); client.println(); printMetricsJson(client, false); } void startEventStream(WiFiClient& client) { // Prefer a free/disconnected slot; only if all are busy, replace one // round-robin so a flood of connections cannot lock everyone out. int slot = -1; for (uint8_t i = 0; i < MAX_SSE_CLIENTS; i++) { if (!sseClients[i] || !sseClients[i].connected()) { slot = i; break; } } if (slot < 0) { slot = nextSseSlot; nextSseSlot = (nextSseSlot + 1) % MAX_SSE_CLIENTS; sseClients[slot].stop(); } sseClients[slot] = client; WiFiClient& sseClient = sseClients[slot]; sseClient.setNoDelay(true); sseClient.setTimeout(5); sseClient.println(F("HTTP/1.1 200 OK")); sseClient.println(F("Content-Type: text/event-stream")); sseClient.println(F("Cache-Control: no-cache")); sseClient.println(F("Connection: keep-alive")); // Chunked encoding so proxies (Cloudflare quick tunnel) flush each event // immediately instead of buffering the whole stream until EOF. sseClient.println(F("Transfer-Encoding: chunked")); sseClient.println(F("Access-Control-Allow-Origin: *")); sseClient.println(F("X-Accel-Buffering: no")); sseClient.println(); sseWriteChunk(sseClient, F("retry: 1500\n\n")); sendStatusEvent(sseClient); } void handleHttpClient() { WiFiClient client = httpServer.available(); if (!client) return; client.setTimeout(50); String request = client.readStringUntil('\n'); request.trim(); while (client.connected() && client.available()) { String line = client.readStringUntil('\n'); if (line == "\r" || line.length() <= 1) break; } if (request.startsWith("GET /events")) { startEventStream(client); return; } if (request.startsWith("GET /status")) { sendStatusJson(client); client.stop(); return; } if (request.startsWith("GET /info")) { sendInfoPage(client); client.stop(); return; } sendDashboard(client); client.stop(); } void connectWiFi() { WiFi.mode(WIFI_STA); WiFi.setSleep(false); WiFi.begin(WIFI_SSID, WIFI_PASSWORD); Serial.print("Connecting to Wi-Fi"); while (WiFi.status() != WL_CONNECTED) { delay(300); Serial.print("."); } Serial.println(); Serial.print("Wi-Fi connected. XIAO IP: "); Serial.println(WiFi.localIP()); } void setup() { Serial.begin(115200); delay(300); pinMode(LO_PLUS, INPUT_PULLDOWN); pinMode(LO_MINUS, INPUT_PULLDOWN); analogReadResolution(12); for (uint8_t i = 0; i < FILTER_SIZE; i++) { filterBuffer[i] = analogRead(ECG_PIN); filterSum += filterBuffer[i]; } baseline = filterSum / FILTER_SIZE; connectWiFi(); httpServer.begin(); if (MDNS.begin(MDNS_NAME)) { MDNS.addService("http", "tcp", HTTP_PORT); } Serial.println(); Serial.println("Seebscribe embedded ECG dashboard ready."); Serial.print("Firmware: "); Serial.print(FIRMWARE_LABEL); Serial.print(" ("); Serial.print(FIRMWARE_VERSION); Serial.println(")"); Serial.print("Dashboard: http://"); Serial.print(WiFi.localIP()); Serial.println("/"); Serial.print("Info page: http://"); Serial.print(WiFi.localIP()); Serial.println("/info"); Serial.print("mDNS, if your device supports it: http://"); Serial.print(MDNS_NAME); Serial.println(".local/"); nextSampleAt = micros(); } void loop() { if (WiFi.status() != WL_CONNECTED) { connectWiFi(); } uint32_t nowUs = micros(); uint8_t catchup = 0; while ((int32_t)(nowUs - nextSampleAt) >= 0 && catchup < 4) { sampleOnce(); nextSampleAt += SAMPLE_INTERVAL_US; nowUs = micros(); catchup++; } if (catchup >= 4) { nextSampleAt = micros() + SAMPLE_INTERVAL_US; } handleHttpClient(); uint32_t nowMs = millis(); uint32_t metricsInterval = DIRECT_AD8232_DEMO ? 5000 : 2000; if (nowMs - lastMetricsAt >= metricsInterval) { lastMetricsAt = nowMs; if (!DIRECT_AD8232_DEMO) updateMetricsFromRr(); sendMetricsEvent(); } // Keepalive every 15 s: Cloudflare tunnels drop idle streams, and a // comment line also flushes any proxy buffering between board and browser. if (nowMs - lastSseKeepaliveAt >= 15000) { lastSseKeepaliveAt = nowMs; sseBroadcast(F(": keepalive\n\n")); } if (nowMs - lastStatusPrintAt >= 15000) { lastStatusPrintAt = nowMs; Serial.print("Dashboard: http://"); Serial.print(WiFi.localIP()); Serial.print("/ RSSI: "); Serial.print(WiFi.RSSI()); Serial.print(" dBm BPM: "); Serial.print(bpm); Serial.print(" Beats: "); Serial.println(beatCount); } }