Contents — 25 sections
Project Overview
A wrist band that reads heart-rate variability to estimate autonomic balance, guides paced breathing to shift it, and shows you — honestly — what HRV can and cannot tell you about stress.
Heart-rate variability is one of the more genuinely useful physiological signals a wearable can extract, and also one of the most over-claimed. The signal is real: the interval between heartbeats is not constant, and its variability reflects the balance between the two branches of the autonomic nervous system — sympathetic (fight-or-flight) and parasympathetic (rest-and-digest). When you are relaxed, the parasympathetic system modulates heart rate strongly with breathing, producing high variability. Under stress, sympathetic dominance suppresses this, and variability falls.
This device measures HRV properly from a photoplethysmogram, computes the standard time-domain and frequency-domain metrics, and — its most valuable feature — provides biofeedback. Slow, paced breathing at around six breaths per minute maximises the respiratory modulation of heart rate (respiratory sinus arrhythmia) and increases HRV in real time. The band guides this breathing and shows the HRV rising as you do it, which is both a genuine relaxation technique and a satisfying demonstration that the measurement is real.
The honesty is in the interpretation. HRV is highly individual — a "good" value for one person is a poor value for another, so only within-person comparison is meaningful. It is affected by age, fitness, position, time of day, hydration, and recent food and caffeine. A single reading tells you little; a consistent morning baseline over weeks tells you something. And "stress" as measured by HRV is autonomic arousal, which is not the same as psychological stress, though they correlate. The device presents HRV as a trend and a biofeedback target, not as a stress score to be taken literally.
Built on the same MAX30102 front end as the heart-rate band project, this build adds the frequency-domain analysis (LF/HF ratio) and the paced-breathing biofeedback loop that turns a passive measurement into an active tool.
What this project does
- Measures inter-beat intervals from a wrist PPG signal.
- Computes time-domain HRV (RMSSD, SDNN, pNN50) and frequency-domain metrics (LF, HF, LF/HF).
- Provides paced-breathing biofeedback at a configurable rate around six breaths per minute.
- Shows HRV rising in real time during paced breathing.
- Tracks a personal morning baseline for meaningful within-person comparison.
- Rejects readings corrupted by motion, so it only reports on clean data.
- Logs sessions and baseline trend over time.
Real-World Applications
| Setting | How it is used |
|---|---|
| Stress-management biofeedback | Paced breathing with real-time HRV feedback is an evidence-supported relaxation technique. |
| Training recovery | Morning HRV baseline trends track autonomic recovery and readiness, widely used by athletes. |
| Mindfulness and breathing practice | Objective feedback that a breathing practice is actually shifting your physiology. |
| Autonomic function awareness | A window into the balance between the two branches of the nervous system. |
| Learning physiological signal analysis | Time and frequency-domain HRV, including the subtle problem of spectral analysis on uneven samples. |
| Anxiety self-regulation | A concrete, controllable target during moments of arousal. |
Deployment contexts where a build of this kind earns its keep.
Features & Capabilities
- Full HRV metric suite — time-domain and frequency-domain, computed correctly.
- Paced-breathing biofeedback at the ~0.1 Hz resonance frequency that maximises HRV.
- Real-time HRV display during breathing, closing the biofeedback loop.
- Personal baseline tracking, because HRV is only meaningful within an individual.
- Frequency analysis via Lomb-Scargle, the correct method for unevenly-sampled RR intervals.
- Motion gating so metrics are computed only on clean, still data.
- Coherence scoring — a measure of how rhythmic and resonant the HRV pattern is.
- Honest framing — HRV as a personal trend and biofeedback target, not an absolute stress score.
Difficulty, Time & Required Skills
| Attribute | Value |
|---|---|
| Difficulty level | Intermediate |
| Estimated completion time | 12–18 hours |
| Indicative build cost | ₹2,600 – ₹3,300 |
| Primary discipline | Health & Wearables |
| Reference platform | ESP32 DevKit V1 (ESP-WROOM-32) |
Skills you should have (or will pick up)
- Arduino C++ with signal processing
- PPG peak detection (from the heart-rate band project)
- Time and frequency-domain HRV computation
- The Lomb-Scargle periodogram for uneven sampling
- BLE and simple UI/biofeedback design
Bill of Materials
Every part below is commonly available from Indian and international hobby-electronics suppliers. Prices are indicative 2026 retail figures in Indian rupees and will drift — treat them as a budgeting guide, not a quotation.
| Component | Key specification | Qty | Approx. cost |
|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly. | Dual-core Xtensa LX6 @ 240 MHz, 520 KB SRAM, 4 MB flash, Wi-Fi 802.11 b/g/n + BLE 4.2, 34 GPIO, 18× 12-bit ADC, 2× 8-bit DAC | 1 | ₹450 |
| MAX30102 pulse oximeter + heart-rate sensor Contact pressure dominates signal quality — a loose sensor gives a beautiful-looking, meaningless waveform. | Red 660 nm + IR 880 nm LEDs, 18-bit ADC, 50–3200 SPS, on-chip temperature | 1 | ₹380 |
| ADXL345 3-axis accelerometer Built-in activity/free-fall interrupts let the MCU deep-sleep until something moves. | ±2/4/8/16 g, 13-bit, 0.004 g/LSB, tap and free-fall interrupts, 3200 Hz | 1 | ₹220 |
| 0.96″ SSD1306 OLED display Static images burn in — invert or scroll the screen periodically. | 128 × 64 monochrome, 1.3–3.3 V logic, 100 kHz–400 kHz I²C | 1 | ₹250 |
| 18650 Li-ion cell 3400 mAh + holder Never charge below 0 °C; always use a protected cell or a BMS. | 3.7 V nominal, 4.2 V full, 3400 mAh, ~12.6 Wh, 2 C discharge | 1 | ₹450 |
| TP4056 Li-ion charger + DW01 protection Buy the version *with* protection ICs — the bare charger will over-discharge your cell. | 1 A programmable CC/CV charge to 4.2 V ±1 %, over-discharge and short protection | 1 | ₹45 |
| Double-sided perfboard 7 × 9 cm + headers Solder female headers so the MCU can be swapped without desoldering. | FR-4, 0.1″ pitch, plated through-holes, 24 × 18 grid | 1 | ₹60 |
| Vibration motor + driver Haptic pacing lets you keep your eyes closed during a breathing session. | 10 mm coin, for breathing pacing haptics | 1 | ₹120 |
| Wrist enclosure + strap | TPU/PETG, sensor flush to skin, light seal | 1 | ₹250 |
| 400 mAh LiPo cell | 3.7 V protected | 1 | ₹220 |
Estimated total: ₹2,445, excluding tools, shipping and consumables.
Tools and consumables
- Soldering iron (temperature controlled, 350 °C) with 0.8 mm 60/40 or lead-free solder
- Digital multimeter — continuity, DC volts and current ranges
- Wire strippers, flush cutters and a small set of precision screwdrivers
- Heat-shrink tubing and a heat gun (or a lighter, carefully)
- A laptop with a USB port and the toolchain listed above
Hardware Specifications
| Part | Specification | Supply | Interface | Reference |
|---|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) | Dual-core Xtensa LX6 @ 240 MHz, 520 KB SRAM, 4 MB flash, Wi-Fi 802.11 b/g/n + BLE 4.2, 34 GPIO, 18× 12-bit ADC, 2× 8-bit DAC | 3.3 V logic / 5 V USB | UART, SPI, I²C, I²S, CAN, PWM | Datasheet |
| MAX30102 pulse oximeter + heart-rate sensor | Red 660 nm + IR 880 nm LEDs, 18-bit ADC, 50–3200 SPS, on-chip temperature | 1.8 V + 3.3 V (module regulates) | I²C (0x57) | Datasheet |
| ADXL345 3-axis accelerometer | ±2/4/8/16 g, 13-bit, 0.004 g/LSB, tap and free-fall interrupts, 3200 Hz | 2.0–3.6 V | I²C / SPI | Datasheet |
| 0.96″ SSD1306 OLED display | 128 × 64 monochrome, 1.3–3.3 V logic, 100 kHz–400 kHz I²C | 3.3–5 V | I²C (0x3C) | Datasheet |
| 18650 Li-ion cell 3400 mAh + holder | 3.7 V nominal, 4.2 V full, 3400 mAh, ~12.6 Wh, 2 C discharge | 3.0–4.2 V | Holder / spot-welded tabs | Datasheet |
| TP4056 Li-ion charger + DW01 protection | 1 A programmable CC/CV charge to 4.2 V ±1 %, over-discharge and short protection | 4.5–5.5 V in | micro-USB / pads | Datasheet |
| Double-sided perfboard 7 × 9 cm + headers | FR-4, 0.1″ pitch, plated through-holes, 24 × 18 grid | — | — | Datasheet |
Consolidated electrical and interface specifications for every active part in the build.
Power Budget & Supply Sizing
Add up the typical active current of every part, then size the supply with at least 50 % headroom so transmit bursts and motor inrush never brown out the controller.
| Load | Supply rail | Typical current (mA) | Notes |
|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) | 3.3 V logic / 5 V USB | 160 | Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly. |
| MAX30102 pulse oximeter + heart-rate sensor | 1.8 V + 3.3 V (module regulates) | 5.5 | Contact pressure dominates signal quality — a loose sensor gives a beautiful-looking, meaningless waveform. |
| ADXL345 3-axis accelerometer | 2.0–3.6 V | 0.14 | Built-in activity/free-fall interrupts let the MCU deep-sleep until something moves. |
| 0.96″ SSD1306 OLED display | 3.3–5 V | 20 | Static images burn in — invert or scroll the screen periodically. |
| TP4056 Li-ion charger + DW01 protection | 4.5–5.5 V in | 1000 | Buy the version *with* protection ICs — the bare charger will over-discharge your cell. |
Summed typical draw is 1185.6399999999999 mA. With a 1.5× design margin the supply should deliver at least 1800 mA continuously at the stated rail voltage.
Software Requirements & Development Environment
Reference toolchain: Arduino IDE 2.3.x with the ESP32 board package 3.x (or PlatformIO on VS Code). Anything newer normally works; anything older may lack the board definitions used here.
- Install the Arduino IDE 2.3.x (or PlatformIO if you prefer a real editor and dependency locking).
- Add
https://espressif.github.io/arduino-esp32/package_esp32_index.jsonunder File → Preferences → Additional Board Manager URLs, then install esp32 from the Boards Manager. - Set the correct port under Tools → Port. On Linux add yourself to the
dialoutgroup:sudo usermod -aG dialout $USERand log out and back in. - Open the Serial Monitor at 115200 baud — every sketch here logs its state there.
- Keep File → Preferences → Show verbose output during: compilation switched on while you are debugging build errors.
Required libraries
| Library | Why it is needed | Install |
|---|---|---|
| ArduinoJson 7.x | Zero-allocation JSON serialisation and parsing. | Library Manager → "ArduinoJson" by Benoit Blanchon |
| Adafruit SSD1306 + GFX 2.5.x | Framebuffer and text/graphics primitives for the OLED. | Library Manager → "Adafruit SSD1306" |
| Preferences (NVS) bundled | Wear-levelled key/value storage in ESP32 flash for settings. | Bundled with the ESP32 core |
Block Diagram
The block diagram shows the functional decomposition of the system — what senses, what decides, what acts, and where the data ends up.
Circuit Diagram & Wiring
Every signal line in the build is shown below, followed by a pin-by-pin connection table you can work through with a multimeter in hand.
| Peripheral | Peripheral pin | Controller pin | Signal |
|---|---|---|---|
| MAX30102 PPG | SDA / SCL / INT | GPIO 21 / 22 / 27 | I²C at 0x57 |
| ADXL345 motion | SDA / SCL | GPIO 21 / 22 | Shared I²C, 0x53 |
| Button | NO | GPIO 33 | Start session / mode |
| SSD1306 OLED | SDA / SCL | GPIO 21 / 22 | Shared I²C, breathing guide |
| Vibration motor | Transistor base | GPIO 25 | Breathing pacing haptic |
| Status LED | Anode | GPIO 26 | Coherence indicator |
Wire one row at a time and tick it off — most "it does not work" reports trace back to a single swapped pair.
Wiring explanation
- The MAX30102 must contact skin directly with no air gap and a light seal around it — exactly as in the heart-rate band project. HRV needs clean, precise R-peak timing, so signal quality matters even more here than for a bare heart rate.
- The accelerometer must be rigidly attached to the same body as the PPG sensor so its motion signal corresponds to the sensor's motion for gating.
- A green LED PPG channel (if your module has one) is more motion-robust for beat detection; red/IR is fine for still HRV sessions, which is the intended use.
- Keep the I²C at 400 kHz for reliable high-rate PPG sampling.
- The vibration motor for breathing pacing needs a transistor and flyback diode; a GPIO cannot drive it.
- For a wrist device intended for still seated sessions, motion is less of a problem than for continuous wear — but the gating still matters because even small wrist movements corrupt the fine RR timing HRV depends on.
System Architecture
Read the stack from the bottom up: physical hardware, the firmware that drives it, the transport that moves data off the device, and the software a human actually looks at.
Working Principle
Heart rate is not a metronome. Even at rest, the interval between successive beats varies from one beat to the next, and that variation carries information about the autonomic nervous system. The parasympathetic branch (via the vagus nerve) can change heart rate quickly, beat to beat, and is strongly influenced by breathing — heart rate rises on inhalation and falls on exhalation, a phenomenon called respiratory sinus arrhythmia. The sympathetic branch acts more slowly. When you are relaxed, vagal tone is high and beat-to-beat variability is large; under stress or exertion, sympathetic dominance suppresses the fast variability.
The time-domain metrics quantify this directly from the RR intervals. RMSSD (root mean square of successive differences) captures the fast beat-to-beat changes and so primarily reflects parasympathetic activity — it is the metric most used for short recordings and biofeedback. SDNN (standard deviation of all intervals) captures overall variability across all timescales. pNN50 (the proportion of successive intervals differing by more than 50 ms) is another parasympathetic index.
The frequency-domain metrics decompose the variability by rhythm. High-frequency (HF, 0.15–0.4 Hz) power corresponds to the respiratory rhythm and reflects parasympathetic activity. Low-frequency (LF, 0.04–0.15 Hz) power reflects a mix of sympathetic and parasympathetic influence and a baroreflex rhythm around 0.1 Hz. The LF/HF ratio is sometimes taken as a "sympathovagal balance", though this interpretation is contested and should be treated cautiously.
Computing the frequency spectrum correctly requires care, because RR intervals are unevenly sampled — they occur at the (irregular) beat times, not at a fixed rate. Applying an ordinary FFT requires first interpolating onto an even grid, which distorts the spectrum. The Lomb-Scargle periodogram computes the spectrum directly from unevenly-sampled data without interpolation, and is the statistically correct method for HRV frequency analysis.
The biofeedback is where this becomes an active tool. There is a resonance frequency of the cardiovascular system, typically around 0.1 Hz (six breaths per minute), at which breathing produces the largest heart-rate oscillations. Breathing at this rate maximises HRV and drives the system into a state of high "coherence" — a smooth, sinusoidal heart-rate pattern. This is both a measurable phenomenon and an effective relaxation technique: the device paces your breathing to this rate and shows the HRV rising as you do it, closing a genuine biofeedback loop.
The essential caveat is individuality. HRV varies enormously between people — a healthy 25-year-old athlete and a healthy 60-year-old can differ by a factor of five — so absolute values are almost meaningless across individuals. It also varies within a person by time of day, position, breathing, hydration, recent food and caffeine, and recent exercise. This is why the device tracks a personal baseline (ideally measured each morning in a consistent position) and reports change relative to that, never an absolute "stress score" compared to a population.
The maths behind it
Time-domain HRV
Given RR intervals (ms): RR[1..N]
meanRR = (1/N) Σ RR
SDNN = sqrt( (1/(N-1)) Σ (RR - meanRR)² )
RMSSD = sqrt( (1/(N-1)) Σ (RR[i+1] - RR[i])² )
pNN50 = 100 · count(|RR[i+1]-RR[i]| > 50 ms) / (N-1)
RMSSD is the primary short-term / biofeedback metric
(parasympathetic). Typical resting adult RMSSD: 20–90 ms,
but only compare a person against themselves.
Lomb-Scargle periodogram (uneven sampling)
RR intervals occur at beat times t_k, not on an even grid,
so an FFT would require interpolation and distort the spectrum.
For frequency ω, the Lomb-Scargle power:
τ chosen so that Σ sin(2ω(t_k − τ)) = 0
P(ω) = (1/2)[ (Σ x_k cos ω(t_k−τ))² / Σ cos²ω(t_k−τ)
+ (Σ x_k sin ω(t_k−τ))² / Σ sin²ω(t_k−τ) ]
Integrate P(ω) over:
LF band: 0.04–0.15 Hz HF band: 0.15–0.40 Hz
LF/HF = LF power / HF power
Coherence and resonance
Resonance frequency ≈ 0.1 Hz → 6 breaths/min
(period 10 s: 4–5 s inhale, 5–6 s exhale is comfortable)
Coherence: how concentrated the HRV spectrum is around
the breathing frequency.
coherence = peak_power_in_0.04-0.26Hz
/ total_power_in_0.04-0.26Hz
High coherence (smooth, sinusoidal heart-rate pattern)
is the biofeedback target during paced breathing.
Program Flowchart
The firmware is a single cooperative loop. Nothing blocks for long, so networking, sensing and the user interface all stay responsive.
Assembly Instructions
Build on a breadboard first and only commit to solder once the whole system has run for an hour without a fault.
Step-by-Step Implementation Guide
Work through these in order. Each step ends in something you can observe, so a failure is always localised to the step you just finished.
Complete Source Code
The listing below is complete and compiles as written — there are no elided sections. Read the annotations under each block before you upload it.
/* ═══════════════════════════════════════════════════════════════
Stress & HRV Wearable — ESP32 + MAX30102 + ADXL345
Computes time-domain and frequency-domain HRV, and provides paced-
breathing biofeedback at the ~0.1 Hz resonance frequency.
HRV is highly individual. Compare a person only against their own
baseline. This is a wellness and biofeedback tool, not a diagnosis.
══════════════════════════════════════════════════════════════════ */
#include <Wire.h>
#include <Adafruit_SSD1306.h>
#include <Adafruit_ADXL345_U.h>
#include <Preferences.h>
#include <math.h>
#define PIN_VIBE 25
#define PIN_BTN 33
#define PIN_LED 26
#define RR_MAX 128
#define BREATH_S 10.0f // 6 breaths/min resonance
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
Adafruit_ADXL345_Unified accel(4);
Preferences prefs;
// (PPG beat detection from the heart-rate band project supplies this.)
extern bool ppgBeat(uint32_t &rrMs); // returns true on a new beat, gives RR
extern bool ppgClean(); // signal quality gate
extern float accelVariance();
float rr[RR_MAX];
uint32_t rrT[RR_MAX]; // beat timestamps for Lomb-Scargle
int rrCount = 0;
float rmssd = 0, sdnn = 0, lf = 0, hf = 0, lfhf = 0, coherence = 0;
float baselineRmssd = 0;
bool breathing = false;
/* ── time-domain ────────────────────────────────────────────── */
void timeDomain() {
if (rrCount < 8) return;
float mean = 0; for (int i = 0; i < rrCount; i++) mean += rr[i]; mean /= rrCount;
double var = 0, succ = 0;
for (int i = 0; i < rrCount; i++) { float d = rr[i] - mean; var += d * d; }
for (int i = 1; i < rrCount; i++) { float d = rr[i] - rr[i - 1]; succ += (double)d * d; }
sdnn = sqrt(var / (rrCount - 1));
rmssd = sqrt(succ / (rrCount - 1));
}
/* ── frequency-domain via Lomb-Scargle ──────────────────────── */
float lombPower(float freq) {
float w = 2.0f * (float)M_PI * freq;
float mean = 0; for (int i = 0; i < rrCount; i++) mean += rr[i]; mean /= rrCount;
// tau such that the sine sum vanishes.
float s2 = 0, c2 = 0;
for (int i = 0; i < rrCount; i++) {
float t = rrT[i] / 1000.0f;
s2 += sinf(2 * w * t); c2 += cosf(2 * w * t);
}
float tau = atan2f(s2, c2) / (2 * w);
float xc = 0, xs = 0, cc = 0, ss = 0;
for (int i = 0; i < rrCount; i++) {
float t = rrT[i] / 1000.0f;
float x = rr[i] - mean;
float co = cosf(w * (t - tau)), si = sinf(w * (t - tau));
xc += x * co; xs += x * si; cc += co * co; ss += si * si;
}
return 0.5f * ((cc > 0 ? xc * xc / cc : 0) + (ss > 0 ? xs * xs / ss : 0));
}
void frequencyDomain() {
if (rrCount < 20) return; // need enough beats for the spectrum
lf = hf = 0;
float peak = 0, total = 0;
for (float f = 0.04f; f <= 0.40f; f += 0.005f) {
float p = lombPower(f);
if (f < 0.15f) lf += p; else hf += p;
if (f <= 0.26f) { total += p; if (p > peak) peak = p; }
}
lfhf = hf > 0 ? lf / hf : 0;
coherence = total > 0 ? peak / total : 0;
}
/* ── paced-breathing biofeedback ────────────────────────────── */
void breathingGuide(uint32_t now) {
// A rising/falling bar paced at the resonance frequency, plus haptics
// at the inhale/exhale transitions so you can keep your eyes closed.
float phase = fmodf(now / 1000.0f, BREATH_S) / BREATH_S; // 0..1
bool inhale = phase < 0.45f;
static bool wasInhale = false;
if (inhale != wasInhale) { digitalWrite(PIN_VIBE, HIGH); delay(60); digitalWrite(PIN_VIBE, LOW); wasInhale = inhale; }
oled.clearDisplay(); oled.setTextColor(SSD1306_WHITE);
oled.setTextSize(2); oled.setCursor(0, 0);
oled.print(inhale ? "Breathe IN" : "Breathe OUT");
int barH = inhale ? (int)(phase / 0.45f * 30) : (int)((1 - (phase - 0.45f) / 0.55f) * 30);
oled.fillRect(0, 30, barH * 4, 8, SSD1306_WHITE);
oled.setTextSize(1);
oled.setCursor(0, 44); oled.printf("RMSSD %.0f ms", rmssd);
oled.setCursor(0, 54); oled.printf("coherence %.2f", coherence);
oled.display();
// LED brightens with coherence — a glanceable biofeedback signal.
analogWrite(PIN_LED, (int)(coherence * 255));
}
/* ── setup / loop ───────────────────────────────────────────── */
void setup() {
Serial.begin(115200);
pinMode(PIN_VIBE, OUTPUT);
pinMode(PIN_BTN, INPUT_PULLUP);
pinMode(PIN_LED, OUTPUT);
Wire.begin(21, 22); Wire.setClock(400000);
oled.begin(SSD1306_SWITCHCAPVCC, 0x3C);
accel.begin(0x53);
prefs.begin("hrv", false);
baselineRmssd = prefs.getFloat("base", 0);
Serial.println("HRV band — compare to YOUR baseline only");
}
void loop() {
if (digitalRead(PIN_BTN) == LOW) { delay(50); if (digitalRead(PIN_BTN) == LOW) {
breathing = !breathing; while (digitalRead(PIN_BTN) == LOW) delay(20); } }
uint32_t now = millis();
uint32_t rrMs;
if (ppgClean() && accelVariance() < 0.15f && ppgBeat(rrMs)) {
if (rrMs > 300 && rrMs < 2000) {
// Slide the RR window.
if (rrCount >= RR_MAX) {
memmove(rr, rr + 1, sizeof(float) * (RR_MAX - 1));
memmove(rrT, rrT + 1, sizeof(uint32_t) * (RR_MAX - 1));
rrCount--;
}
rr[rrCount] = rrMs;
rrT[rrCount] = now;
rrCount++;
timeDomain();
}
}
static uint32_t lastFreq = 0;
if (now - lastFreq > 5000) { lastFreq = now; frequencyDomain(); }
if (breathing) {
breathingGuide(now);
} else {
oled.clearDisplay(); oled.setTextColor(SSD1306_WHITE);
oled.setTextSize(1); oled.setCursor(0, 0); oled.print("HRV");
oled.setTextSize(2); oled.setCursor(0, 12); oled.printf("%.0f ms", rmssd);
oled.setTextSize(1);
oled.setCursor(0, 34);
if (baselineRmssd > 0)
oled.printf("baseline %.0f (%+.0f%%)", baselineRmssd,
(rmssd - baselineRmssd) / baselineRmssd * 100);
else oled.print("no baseline yet");
oled.setCursor(0, 46); oled.printf("LF/HF %.2f SDNN %.0f", lfhf, sdnn);
oled.setCursor(0, 56); oled.print("Personal trend only");
oled.display();
}
static uint32_t lastLog = 0;
if (now - lastLog > 3000) {
lastLog = now;
Serial.printf("RMSSD %.0f SDNN %.0f LF/HF %.2f coh %.2f n=%d\n",
rmssd, sdnn, lfhf, coherence, rrCount);
}
}
Configuration & Calibration
Configuration steps
- Set the PPG front end up as in the heart-rate band project — good skin contact and a light seal are prerequisites, because HRV needs clean beat timing.
- Set
BREATH_Sto the user's resonance frequency. Six breaths per minute (10 s) is the population average, but individual resonance ranges from about 4.5 to 7 breaths per minute; a slower sweep to find each person's resonance is worthwhile. - Establish a personal baseline by taking several morning readings in a consistent position (seated or lying) and averaging them. Only compare against this.
- Set the motion gate from your own accelerometer variance while sitting still — HRV sessions should be still, so the gate can be fairly tight.
- Collect at least 20 beats (about 20 seconds) before trusting the frequency-domain metrics; more is better for spectral resolution.
Calibration procedure
An uncalibrated sensor produces confident, precise, wrong numbers. Do this once per physical unit and record the constants.
Find your resonance frequency
Breathe at 4.5, 5, 5.5, 6, 6.5 and 7 breaths per minute for two minutes each and note which gives the highest RMSSD and coherence. That is your personal resonance frequency; set the pacing to it. It is usually between 5 and 6.5 breaths per minute.
Establish a morning baseline
For a week, take a five-minute reading each morning immediately after waking, in the same position, before caffeine or exercise. Average the RMSSD values. This baseline is the only meaningful reference for your subsequent readings.
Validate the metrics against an ECG if possible
If you built the ECG project, compare HRV metrics from both on the same session. PPG-derived HRV is slightly noisier than ECG-derived, but for still biofeedback sessions they should agree closely. Large differences point to PPG signal-quality problems.
Confirm the biofeedback loop works
Do a paced-breathing session and watch RMSSD and coherence rise as you settle into the rhythm. If they do not respond, the beat timing is too noisy — improve the PPG contact and light seal.
Testing Procedure & Expected Output
Test from the bottom up. Confirm power, then each sensor in isolation, then the integrated loop — the first failing step tells you exactly where to look.
| Test | What you should see |
|---|---|
| Sit still and read HRV at rest | A stable RMSSD in a physiological range, repeatable across a session. |
| Do a paced-breathing session at ~6 breaths/min | RMSSD and coherence rise noticeably as you settle into the rhythm — the biofeedback loop closing. |
| Compare metrics to a still baseline | The display shows change relative to the personal baseline, not an absolute score. |
| Move your wrist during a reading | The motion gate pauses metric computation rather than reporting a corrupted (falsely low) HRV. |
| Breathe at a fast, shallow rate | HF power and RMSSD fall; the metrics respond to the change in breathing, demonstrating they are real. |
| Check the frequency-domain metrics after 30 s | Plausible LF and HF power with LF/HF around 1–2 at rest; during resonant breathing, HF and coherence dominate. |
| Take morning readings over a week | A baseline emerges; day-to-day variation is normal, and a sustained drop may reflect fatigue or illness. |
| Verify against the ECG project if built | Close agreement in RMSSD for still sessions, confirming the PPG HRV is trustworthy. |
Bench-test checklist. If a row fails, stop and fix it before moving on.
Expected output
With everything wired and the firmware uploaded, the Serial Monitor at 115200 baud should look similar to the trace below. Values will differ; the shape of the output should not.
Troubleshooting: Common Errors & Fixes
Performance Optimisation
- Compute time-domain metrics on every new beat (cheap) but frequency-domain metrics only every few seconds (the Lomb-Scargle is more expensive).
- Keep a rolling RR window rather than recomputing from scratch, and cap it at a couple of minutes — longer windows blur genuine changes.
- Gate hard on motion. A clean 30-second still reading gives better HRV than a noisy five-minute one.
- Replace every
delay()with amillis()comparison — blocking delays are the single most common cause of dropped readings. - Sample sensors on a fixed cadence and publish on a slower one; you almost never need to transmit at the sampling rate.
- Move networking into its own FreeRTOS task so a slow DNS lookup cannot stall the control loop.
- Use
uint8_t/uint16_twhere the range allows; on an 8-bit AVR a 32-bit add costs four times as much. - For battery builds use deep sleep between samples: an ESP32 drops from ~160 mA awake to about 10 µA asleep, which is the difference between days and months of runtime.
- Profile before optimising — print
micros()deltas around each stage and fix the slowest one first.
Safety Precautions
- This is a wellness and biofeedback tool, not a medical device. HRV is not a diagnosis of anything, and a low reading does not mean you are ill.
- Do not use HRV readings to make medical decisions or to override how you actually feel. If you are unwell, seek care regardless of what your HRV shows.
- Paced slow breathing is generally safe and relaxing, but if you feel dizzy or lightheaded, stop and breathe normally — do not force a rate that is uncomfortable.
- HRV can be a useful signal for training recovery, but it is one input among many; do not let a number override good judgement about rest and load.
- Lithium cells vent and burn when abused. Only use protected cells or a proper BMS, never charge below 0 °C, and never leave a charging pack unattended on a wooden desk.
- Wear eye protection when soldering or cutting, and solder in a ventilated space — rosin flux fumes are a respiratory irritant.
- Power the circuit through a bench supply with a current limit while you are testing. A 300 mA limit turns a wiring mistake into a beep instead of a dead board.
- Disconnect power before changing any wiring. Hot-plugging a sensor onto a live bus is the fastest way to lose a controller.
Maintenance
- Re-check every screw terminal and header after the first week — thermal cycling loosens connections that felt tight on day one.
- Log pack voltage. When resting voltage after a full charge drops below about 4.0 V, the cell is near end of life — replace it.
- Recalibrate at the interval given in the calibration section, and keep the constants in a text file next to the firmware — not only in flash.
- Keep a short logbook of firmware versions and what changed. Six months later you will not remember why that constant is 1.083.
Future Improvements & Upgrades
A working v1 is a platform, not a finish line. These are the upgrades that add the most capability for the least rework.
- Add a resonance-frequency finder that automatically sweeps breathing rates and identifies each user's personal resonance.
- Add guided sessions of varying length with progress tracking, turning it into a proper biofeedback trainer.
- Add sleep HRV measurement — overnight HRV is the most standardised and informative, free of the confounds of daytime activity.
- Add ECG input (from the ECG project) as an option, giving gold-standard beat timing for the most accurate HRV.
- Add a readiness score combining morning HRV, resting heart rate and their trends — but present it as guidance, never as a command.
- Design a proper PCB. Once the breadboard version has run for a month, moving to a two-layer board removes the intermittent-contact failures that dominate prototype faults.
- Add connectivity — an ESP32 and an MQTT publish turn a local gadget into something you can graph, alert on and analyse over months.
- Add persistent local storage (microSD or the on-chip flash) so a network outage does not create a hole in your data.
- Move configuration out of the source: a captive-portal setup page or a JSON config file makes the build reusable without a recompile.
- Add a battery and solar option so the unit survives a power cut and can be sited away from a socket.
- Write a small test harness that feeds synthetic sensor values through the decision logic, so you can validate thresholds without physically triggering the event.
Frequently Asked Questions
References & Learning Resources
These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.
- Shaffer & Ginsberg, "An Overview of Heart Rate Variability Metrics and Norms"Frontiers in Public Health, 2017
- Task Force of ESC/NASPE, "Heart rate variability: standards of measurement"Circulation, 1996
- Lehrer & Gevirtz, "Heart rate variability biofeedback: how and why does it work?"Frontiers in Psychology, 2014
- Lomb, "Least-squares frequency analysis of unequally spaced data"Astrophysics and Space Science, 1976
- MAX30102 pulse oximeter and heart-rate sensor — datasheetAnalog Devices
- Laborde et al., "Heart Rate Variability and Cardiac Vagal Tone in Psychophysiological Research"Frontiers in Psychology, 2017