Siddhant Kumar
Project 019 · Health & Wearables

Continuous Fever Patch.

A skin patch that streams axillary temperature every thirty seconds over BLE — with the thermal modelling that turns a skin reading into something that actually tracks core temperature.

Intermediate 10–14 hours 36 min read SensorBLEHealth
Jump to source Bill of materials
Continuous Fever Patch — reference build illustration MCU VCC · GND · SIG · NC
Difficulty
Intermediate
Build time
10–14 hours
Indicative cost
₹2,400 – ₹3,400
Platform
ESP32 DevKit V1 (ESP-WROOM-32)
Category
Health & Wearables
Last updated
28 July 2026
Contents — 25 sections

Project Overview

A skin patch that streams axillary temperature every thirty seconds over BLE — with the thermal modelling that turns a skin reading into something that actually tracks core temperature.

A thermometer under the arm is a spot check. What matters clinically in a feverish child is the trajectory: is the temperature climbing, has an antipyretic taken effect, did it spike at 3 a.m. while everyone was asleep. Continuous measurement answers those questions and a spot check does not, which is why continuous patches exist commercially at ten times the cost of this build.

The hard part is not measurement — a DS18B20 or an MAX30205 resolves 0.0625 °C without effort. The hard part is that skin temperature is not core temperature. A sensor against the skin sits in a thermal gradient between the body and the room, and the offset depends on ambient temperature, blood flow, sweat, clothing and how firmly the sensor is held. Reporting raw skin temperature as if it were body temperature is the single most common error in these projects, and it produces readings that are two degrees low and confidently wrong.

This design addresses that with a dual-sensor heat-flux approach. One sensor sits against the skin; a second sits on the outer face of a known insulating layer. The difference between them measures heat flowing outward, and a simple thermal model estimates the temperature at the other end of the gradient — the core. It is the same principle used by commercial zero-heat-flux thermometers, implemented simply.

The rest of the design is about being wearable. It must be small, comfortable, safe against skin for days, run for a week, and — most importantly — must fail visibly rather than silently, because a parent watching a fever needs to know when the device has stopped reporting.

A wrist-worn fitness tracker
A body-worn wearable. A fever patch must be thin, light and comfortable enough to stay attached for days — the measurement is worthless if it is removed. Photograph sourced from Wikimedia Commons — Fitness tracker.jpg. Reused under the licence stated on that page; please check it before republishing.

What this project does

  • Measures skin and outer-surface temperature every 30 seconds with 0.0625 °C resolution.
  • Estimates core temperature using a two-sensor heat-flux model calibrated per wearer.
  • Streams readings over BLE to a phone, with local buffering for up to 12 hours out of range.
  • Alerts on threshold crossing and on rate of rise, both configurable.
  • Detects when the patch has come loose or lost skin contact, and says so rather than reporting garbage.
  • Logs a full temperature trace for review and export.
  • Runs for about a week on a small lithium cell.

Real-World Applications

SettingHow it is used
Paediatric fever monitoringThe primary case — overnight trajectory in a feverish child, without waking them every two hours.
Post-operative infection watchA rising temperature is often the first sign of a surgical site infection, and continuous monitoring catches it a day earlier.
Elderly careOlder adults often mount a blunted fever response; a small but sustained rise is significant and easy to miss on spot checks.
Antipyretic effectivenessSeeing exactly when and how much paracetamol brought a temperature down is genuinely informative.
Ovulation and cycle trackingBasal body temperature shifts of about 0.3 °C are detectable with continuous overnight measurement.
Occupational heat stressThe same hardware with different thresholds monitors workers in hot environments.

Deployment contexts where a build of this kind earns its keep.

Features & Capabilities

  • Dual-sensor heat-flux estimation rather than a naive skin reading plus a fixed offset.
  • Per-wearer calibration against a reference thermometer, stored in flash.
  • Contact detection from the skin-to-ambient gradient — a detached patch is obvious.
  • Rate-of-rise alerting, which catches a developing fever before the absolute threshold.
  • 12-hour local ring buffer so a night out of BLE range loses nothing.
  • Configurable thresholds with separate warning and alert levels.
  • Low-power design at about 60 µA average through aggressive duty cycling.
  • Visible failure: the device reports staleness rather than repeating the last good value.

Difficulty, Time & Required Skills

AttributeValue
Difficulty levelIntermediate
Estimated completion time10–14 hours
Indicative build cost₹2,400 – ₹3,400
Primary disciplineHealth & Wearables
Reference platformESP32 DevKit V1 (ESP-WROOM-32)

Skills you should have (or will pick up)

  • Arduino C++ and 1-Wire or I²C sensor reading
  • Basic heat-transfer intuition — conduction, thermal resistance, gradients
  • Two-point sensor calibration
  • BLE GATT services
  • Low-power design and deep sleep

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.

ComponentKey specificationQtyApprox. 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 DAC1₹450
DS18B20 waterproof temperature probe
Dozens can share one GPIO — you address them by ROM code.
−55 to +125 °C, ±0.5 °C from −10 to +85 °C, 9–12-bit resolution, unique 64-bit ROM ID2₹320
SHT31-D temperature + humidity sensor
The built-in heater burns off condensation — essential for outdoor or greenhouse use.
−40 to +125 °C ±0.2 °C, 0–100 %RH ±2 %, 1 Hz–10 Hz, on-chip heater1₹620
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 discharge1₹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 protection1₹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 grid1₹60
MAX30205 clinical-grade temperature sensor
Better than DS18B20 in the clinically relevant range. Worth the upgrade if accuracy matters.
±0.1 °C from 37–39 °C, 16-bit, I²C2₹1,240
Thermally conductive silicone pad
The insulating layer between the two sensors. Its thermal resistance must be known and consistent.
1 mm, 1.5 W/m·K, known thickness1₹180
Hypoallergenic medical adhesive patches50 mm, breathable, 3M 1776 or similar20₹6,400
150 mAh LiPo cell3.7 V, protected, 4 mm thick1₹240
Flexible PCB or thin perfboard + silicone pottingSkin-safe encapsulation1₹300

Estimated total: ₹10,305, 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

PartSpecificationSupplyInterfaceReference
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 DAC3.3 V logic / 5 V USBUART, SPI, I²C, I²S, CAN, PWMDatasheet
DS18B20 waterproof temperature probe−55 to +125 °C, ±0.5 °C from −10 to +85 °C, 9–12-bit resolution, unique 64-bit ROM ID3.0–5.5 V1-Wire (multi-drop)Datasheet
SHT31-D temperature + humidity sensor−40 to +125 °C ±0.2 °C, 0–100 %RH ±2 %, 1 Hz–10 Hz, on-chip heater2.4–5.5 VI²C (0x44/0x45)Datasheet
18650 Li-ion cell 3400 mAh + holder3.7 V nominal, 4.2 V full, 3400 mAh, ~12.6 Wh, 2 C discharge3.0–4.2 VHolder / spot-welded tabsDatasheet
TP4056 Li-ion charger + DW01 protection1 A programmable CC/CV charge to 4.2 V ±1 %, over-discharge and short protection4.5–5.5 V inmicro-USB / padsDatasheet
Double-sided perfboard 7 × 9 cm + headersFR-4, 0.1″ pitch, plated through-holes, 24 × 18 gridDatasheet

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.

LoadSupply railTypical current (mA)Notes
ESP32 DevKit V1 (ESP-WROOM-32)3.3 V logic / 5 V USB160Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly.
DS18B20 waterproof temperature probe3.0–5.5 V3Dozens can share one GPIO — you address them by ROM code.
SHT31-D temperature + humidity sensor2.4–5.5 V1.5The built-in heater burns off condensation — essential for outdoor or greenhouse use.
TP4056 Li-ion charger + DW01 protection4.5–5.5 V in1000Buy the version *with* protection ICs — the bare charger will over-discharge your cell.

Summed typical draw is 1164.5 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.json under 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 dialout group: sudo usermod -aG dialout $USER and 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

LibraryWhy it is neededInstall
OneWire + DallasTemperature 2.3.x / 3.9.xBus enumeration and conversion commands for DS18B20 probes.Library Manager → "DallasTemperature" (pulls OneWire)
Preferences (NVS) bundledWear-levelled key/value storage in ESP32 flash for settings.Bundled with the ESP32 core
ArduinoJson 7.xZero-allocation JSON serialisation and parsing.Library Manager → "ArduinoJson" by Benoit Blanchon

Block Diagram

The block diagram shows the functional decomposition of the system — what senses, what decides, what acts, and where the data ends up.

Continuous Fever Patch — system block diagramFunctional block diagram of the Continuous Fever Patch system. MeasureSkin sensorT_skinOuter sensorT_outerSHT31T_ambientModelHeat-flux estimategradient → coreContact checkattached?AssessThreshold + ratewarn / alertRing buffer12 h localReportBLE notifyto phoneAlerton crossingthree tempsT_core estreading
Continuous Fever Patch — system block diagram

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.

Continuous Fever Patch — wiring schematicConnection schematic showing which controller pin drives each peripheral. Sensors / InputsControllerActuators / OutputsESP32 DevKit V1(ESP-WROOM-32)3.3 V logic / 5 V USBSkin-side temperaturesensorGPIO 271-Wire with 4.7 kΩpull-upOuter-side temperaturesensorGPIO 27Same bus, differentROM IDSHT31 ambient referenceGPIO 21 / 22I²C at 0x44Battery dividerGPIO 341 MΩ / 1 MΩ, gatedStatus LEDGPIO 25Very brief flashesonlyBuzzer (optional)GPIO 26Threshold alertSensor power gateGPIO 14Cuts sensor powerbetween samples
Continuous Fever Patch — wiring schematic
PeripheralPeripheral pinController pinSignal
Skin-side temperature sensorDATA / SDAGPIO 271-Wire with 4.7 kΩ pull-up
Outer-side temperature sensorDATA / SDAGPIO 27Same bus, different ROM ID
SHT31 ambient referenceSDA / SCLGPIO 21 / 22I²C at 0x44
Battery dividerMid-pointGPIO 341 MΩ / 1 MΩ, gated
Status LEDAnodeGPIO 25Very brief flashes only
Buzzer (optional)+GPIO 26Threshold alert
Sensor power gateMOSFET gateGPIO 14Cuts sensor power between samples

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

  • Both DS18B20 sensors share one GPIO — that is the point of 1-Wire. Each has a unique 64-bit ROM address, so you address them individually on the same two wires. Scan and record both addresses before writing the application code.
  • The skin-side sensor must contact skin directly through a thin thermally conductive layer, and the outer sensor must be separated from it by an insulator of known thickness. That known thermal resistance is what makes the heat-flux calculation possible.
  • Encapsulate everything in skin-safe silicone. Bare electronics against skin for days causes irritation, and sweat will corrode exposed copper within a week.
  • Gate the sensor power through a MOSFET. A DS18B20 draws 1.5 mA during conversion and 750 ms per conversion at 12-bit — leaving it powered continuously is most of your battery budget.
  • Keep the whole assembly under about 4 mm thick and under 15 g. Anything bulkier will not stay attached under an arm for a week, and the project value depends entirely on it staying on.
  • Route the battery so it is on the outer face, away from skin. A lithium cell against skin for days is both a comfort and a safety consideration.

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.

Continuous Fever Patch — architecture stackLayered architecture from hardware to user interface. Hardware layerESP32 DevKit V1 (ESP-WROOM-32) · DS18B20 waterproof temperature probe ·SHT31-D temperature + humidity sensorDriver layeronewire · preferences · arduinojsonApplication logicsampling loop · filtering · thresholds · state machinePresentation layerlocal display · serial console · logged output
Continuous Fever Patch — architecture stack

Working Principle

Heat flows from the body core to the environment through a series of thermal resistances: core to skin (mostly determined by blood flow), skin to the patch surface, and patch surface to air. In steady state the same heat flux passes through each stage, and each stage drops a temperature proportional to its thermal resistance. That is exactly Ohm's law with temperature in place of voltage and heat flux in place of current.

A single skin sensor measures one point in that chain and tells you nothing about the gradient. Two sensors separated by a layer of known thermal resistance measure the gradient directly: the temperature difference across that layer, divided by its resistance, gives the heat flux. Knowing the flux and the (estimated) resistance from core to skin lets you extrapolate back to the core.

That extrapolation is where the calibration lives. The core-to-skin resistance is not constant — it varies with vasoconstriction, which itself varies with core temperature, ambient temperature and time of day. A single global constant is a simplification. What makes it workable is calibrating that constant per wearer against a reference thermometer at a known point, and accepting that the estimate degrades when conditions change substantially from the calibration conditions.

The alternative approach, used by high-end clinical devices, is zero-heat-flux: actively heat the outer surface until the gradient across the insulator is zero, at which point no heat flows and the skin temperature equals the core temperature directly. It is elegant and accurate, and it needs a heater drawing tens of milliwatts continuously, which is incompatible with a week of battery life. The passive two-sensor approach is the pragmatic compromise.

Contact detection falls out of the same measurement. When the patch is attached, the skin sensor reads within a few degrees of body temperature and well above ambient. When it detaches, both sensors converge on ambient within a minute or two. Testing for that convergence gives an unambiguous attached/detached signal, which matters enormously: a detached patch reporting 26 °C looks like a healthy reading if you are not checking.

Finally, rate of rise. A fever developing rises at perhaps 0.5–1.5 °C per hour. Detecting that trend catches it before an absolute threshold is crossed, and — more usefully — distinguishes a genuine rise from the normal circadian variation of about 0.5 °C, which happens far more slowly.

The maths behind it

Heat flux and core estimation

plainHeat flux and core estimation
Thermal resistance analogy:
  q = ΔT / R          (heat flux = temperature drop / resistance)

Measured across the known insulator:
  q = (T_skin − T_outer) / R_insulator

Extrapolate back through the core-to-skin resistance:
  T_core ≈ T_skin + q · R_body
         = T_skin + (T_skin − T_outer) · (R_body / R_insulator)
         = T_skin + k · (T_skin − T_outer)

k is the single calibration constant, typically 0.6–1.4.

Worked example, k = 0.9:
  T_skin = 36.1 °C, T_outer = 34.6 °C
  T_core = 36.1 + 0.9 × 1.5 = 37.45 °C

Contact detection

plainContact detection
Attached:
  T_skin − T_ambient  > 4 °C   (body is much warmer than the room)
  T_skin − T_outer    > 0.3 °C (a gradient exists)

Detached (converging on ambient):
  |T_skin − T_ambient| < 2 °C
  |T_skin − T_outer|   < 0.2 °C

Require the attached condition for three consecutive
samples before reporting a temperature at all. A
detached patch reading 26 °C looks healthy if you
are not explicitly checking.

Rate of rise

plainRate of rise
Over a 30-minute window (60 samples at 30 s):

  rate = (T_core[now] − T_core[now−30min]) / 0.5 h   °C/h

Interpretation:
  |rate| < 0.2 °C/h : normal circadian variation
  rate > 0.5 °C/h   : developing fever — warn
  rate > 1.0 °C/h   : rapid rise — alert
  rate < −0.8 °C/h  : antipyretic taking effect

Use a linear least-squares fit over the window rather
than a two-point difference — the two-point version is
dominated by noise on a 0.0625 °C resolution sensor.

Battery budget

plainBattery budget
DS18B20 conversion: 1.5 mA for 750 ms (12-bit)
  2 sensors on one bus, one conversion each 30 s
  = 1.5 mA × 0.75 s / 30 s = 37.5 µA average

SHT31: 1.5 mA for 15 ms every 30 s = 0.75 µA
ESP32 awake: 80 mA for 1.2 s every 30 s = 3.2 mA  ← dominant
ESP32 deep sleep: 10 µA

With BLE advertising only every 5th wake:
  average ≈ 3.3 mA

150 mAh cell → 45 h. Not a week!

Fix: buffer 10 readings, wake the radio once every
5 minutes instead of every 30 s:
  ESP32 active drops to ~0.7 mA average
  → 150 / 0.75 ≈ 200 h ≈ 8 days  ✓

Program Flowchart

The firmware is a single cooperative loop. Nothing blocks for long, so networking, sensing and the user interface all stay responsive.

Continuous Fever Patch — firmware flowchartControl flow through the main program loop. Wake from deep sleep every 30sPower sensors, wait forconversionRead skin, outer and ambientSkin−ambient gradientplausible?attachedreport detachedEstimate core from the heatfluxAbove threshold or risingfast?alertlog onlyNotify over BLE or bufferlocallyCut sensor power, deep sleep
Continuous Fever Patch — firmware flowchart

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.

cppfever-patch.ino
/* ═══════════════════════════════════════════════════════════════
   Continuous Fever Patch — ESP32 + dual DS18B20 + SHT31

   Two temperature sensors separated by a layer of known thermal
   resistance measure the heat flux leaving the skin, which allows a
   core temperature estimate rather than a raw skin reading.

   NOT a clinical thermometer. Confirm any concerning reading with a
   validated device.
   ══════════════════════════════════════════════════════════════════ */

#include <OneWire.h>
#include <DallasTemperature.h>
#include <Wire.h>
#include <Adafruit_SHT31.h>
#include <Preferences.h>
#include <esp_sleep.h>
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
#include <math.h>

#define PIN_ONEWIRE   27
#define PIN_SENSOR_EN 14
#define PIN_LED       25
#define PIN_BUZZ      26
#define PIN_BATT      34
#define PIN_BATT_EN   13

#define SAMPLE_INTERVAL_S   30
#define RADIO_EVERY_N       10        // advertise once per 5 minutes
#define BUFFER_LEN         120        // 1 hour of samples in RTC memory
#define TREND_SAMPLES       60        // 30 minutes for the rate fit

#define WARN_C   37.8f
#define ALERT_C  38.5f
#define RATE_WARN_C_PER_H  0.5f
#define RATE_ALERT_C_PER_H 1.0f

/* BLE Health Thermometer Service UUIDs */
#define HTS_SERVICE     "00001809-0000-1000-8000-00805f9b34fb"
#define HTS_MEASUREMENT "00002a1c-0000-1000-8000-00805f9b34fb"

OneWire           oneWire(PIN_ONEWIRE);
DallasTemperature ds(&oneWire);
Adafruit_SHT31    sht;
Preferences       prefs;

DeviceAddress addrSkin, addrOuter;

/* Survives deep sleep */
RTC_DATA_ATTR float    ring[BUFFER_LEN];
RTC_DATA_ATTR uint32_t ringTime[BUFFER_LEN];
RTC_DATA_ATTR uint16_t ringHead = 0, ringCount = 0;
RTC_DATA_ATTR uint32_t wakeCount = 0;
RTC_DATA_ATTR bool     wasAttached = false;
RTC_DATA_ATTR uint32_t uptimeS = 0;

float kCalib = 0.9f;                 // heat-flux constant, per wearer
float tSkin = 0, tOuter = 0, tAmbient = 0, tCore = 0, rateCperH = 0;
bool  attached = false;

/* ── sensors ────────────────────────────────────────────────── */
void sensorsOn(bool on) {
  pinMode(PIN_SENSOR_EN, OUTPUT);
  digitalWrite(PIN_SENSOR_EN, on ? HIGH : LOW);
  if (on) delay(20);                 // let rails settle before talking
}

bool readAll() {
  ds.requestTemperatures();          // blocking ~750 ms at 12-bit
  tSkin  = ds.getTempC(addrSkin);
  tOuter = ds.getTempC(addrOuter);
  tAmbient = sht.readTemperature();

  if (tSkin == DEVICE_DISCONNECTED_C || tOuter == DEVICE_DISCONNECTED_C) return false;
  if (isnan(tAmbient)) tAmbient = tOuter;    // fall back to the outer sensor
  return true;
}

/* ── attachment detection ───────────────────────────────────── */
bool checkAttached() {
  bool warmerThanRoom = (tSkin - tAmbient) > 4.0f;
  bool gradientExists = (tSkin - tOuter)   > 0.3f;
  return warmerThanRoom && gradientExists;
}

/* ── core estimation ────────────────────────────────────────── */
float estimateCore() {
  // T_core = T_skin + k * (T_skin - T_outer)
  // The bracketed term is proportional to outward heat flux.
  float flux = tSkin - tOuter;
  if (flux < 0) flux = 0;                    // never extrapolate downward
  return tSkin + kCalib * flux;
}

/* ── trend by least squares ─────────────────────────────────── */
float computeRate() {
  int n = ringCount < TREND_SAMPLES ? ringCount : TREND_SAMPLES;
  if (n < 10) return 0;

  // Least squares slope over the last n samples. A two-point
  // difference is dominated by the 0.0625 C quantisation step.
  double sx = 0, sy = 0, sxy = 0, sxx = 0;
  for (int i = 0; i < n; i++) {
    int idx = (ringHead + BUFFER_LEN - 1 - i) % BUFFER_LEN;
    double x = -(double)i * SAMPLE_INTERVAL_S / 3600.0;   // hours, negative
    double y = ring[idx];
    sx += x; sy += y; sxy += x * y; sxx += x * x;
  }
  double denom = n * sxx - sx * sx;
  if (fabs(denom) < 1e-9) return 0;
  return (float)((n * sxy - sx * sy) / denom);            // °C per hour
}

/* ── BLE ────────────────────────────────────────────────────── */
BLECharacteristic *htsChar = nullptr;
bool bleClientConnected = false;

class ServerCb : public BLEServerCallbacks {
  void onConnect(BLEServer *) override    { bleClientConnected = true; }
  void onDisconnect(BLEServer *s) override { bleClientConnected = false; s->startAdvertising(); }
};

void bleBegin() {
  BLEDevice::init("FeverPatch");
  BLEServer *server = BLEDevice::createServer();
  server->setCallbacks(new ServerCb());

  BLEService *svc = server->createService(HTS_SERVICE);
  htsChar = svc->createCharacteristic(
      HTS_MEASUREMENT,
      BLECharacteristic::PROPERTY_INDICATE | BLECharacteristic::PROPERTY_NOTIFY);
  htsChar->addDescriptor(new BLE2902());
  svc->start();
  server->getAdvertising()->addServiceUUID(HTS_SERVICE);
  server->getAdvertising()->start();
}

// Health Thermometer Measurement: flags byte + IEEE-11073 32-bit float
void bleSend(float celsius) {
  if (!htsChar || !bleClientConnected) return;
  int32_t mantissa = (int32_t)lroundf(celsius * 100.0f);
  uint8_t payload[5];
  payload[0] = 0x00;                                   // flags: Celsius
  payload[1] =  mantissa        & 0xFF;
  payload[2] = (mantissa >>  8) & 0xFF;
  payload[3] = (mantissa >> 16) & 0xFF;
  payload[4] = (uint8_t)(int8_t)(-2);                  // exponent 10^-2
  htsChar->setValue(payload, 5);
  htsChar->notify();
}

void flushBuffer() {
  int n = ringCount < 20 ? ringCount : 20;
  for (int i = n - 1; i >= 0; i--) {
    int idx = (ringHead + BUFFER_LEN - 1 - i) % BUFFER_LEN;
    bleSend(ring[idx]);
    delay(30);
  }
}

/* ── calibration ────────────────────────────────────────────── */
void calibrateAgainst(float referenceCore) {
  if (!attached || (tSkin - tOuter) < 0.2f) return;
  kCalib = (referenceCore - tSkin) / (tSkin - tOuter);
  if (kCalib < 0.2f) kCalib = 0.2f;
  if (kCalib > 2.5f) kCalib = 2.5f;
  prefs.putFloat("k", kCalib);
}

/* ── setup runs once per wake ───────────────────────────────── */
void setup() {
  Serial.begin(115200);
  pinMode(PIN_LED, OUTPUT);
  wakeCount++;
  uptimeS += SAMPLE_INTERVAL_S;

  prefs.begin("patch", false);
  kCalib = prefs.getFloat("k", 0.9f);

  sensorsOn(true);
  Wire.begin(21, 22);
  sht.begin(0x44);
  ds.begin();
  ds.setResolution(12);

  // Both sensors share one bus; index 0 and 1 by discovery order.
  if (!ds.getAddress(addrSkin, 0) || !ds.getAddress(addrOuter, 1)) {
    Serial.println("Sensor discovery failed");
    sensorsOn(false);
    esp_sleep_enable_timer_wakeup((uint64_t)SAMPLE_INTERVAL_S * 1000000ULL);
    esp_deep_sleep_start();
  }

  bool ok = readAll();
  sensorsOn(false);

  if (!ok) {
    Serial.println("Read failed");
    esp_sleep_enable_timer_wakeup((uint64_t)SAMPLE_INTERVAL_S * 1000000ULL);
    esp_deep_sleep_start();
  }

  attached = checkAttached();
  tCore = attached ? estimateCore() : NAN;

  if (attached) {
    ring[ringHead] = tCore;
    ringTime[ringHead] = uptimeS;
    ringHead = (ringHead + 1) % BUFFER_LEN;
    if (ringCount < BUFFER_LEN) ringCount++;
    rateCperH = computeRate();
  } else {
    ringCount = 0;                   // a detached period invalidates the trend
  }

  Serial.printf("skin %.2f  outer %.2f  amb %.2f  core %.2f  rate %+.2f C/h  %s\n",
                tSkin, tOuter, tAmbient, tCore, rateCperH,
                attached ? "attached" : "DETACHED");

  bool alert = attached && (tCore >= ALERT_C || rateCperH >= RATE_ALERT_C_PER_H);
  bool warn  = attached && (tCore >= WARN_C  || rateCperH >= RATE_WARN_C_PER_H);

  if (alert) { for (int i = 0; i < 4; i++) { tone(PIN_BUZZ, 2600, 180); delay(240); } }
  else if (warn) { tone(PIN_BUZZ, 2000, 200); }

  if (attached != wasAttached) {
    // An attachment change is worth reporting immediately.
    digitalWrite(PIN_LED, HIGH); delay(60); digitalWrite(PIN_LED, LOW);
    wasAttached = attached;
    wakeCount = RADIO_EVERY_N;       // force a radio wake this cycle
  }

  /* Radio only every Nth wake — this is what makes a week possible. */
  if (wakeCount % RADIO_EVERY_N == 0 || alert) {
    bleBegin();
    uint32_t t0 = millis();
    while (millis() - t0 < 3000 && !bleClientConnected) delay(50);
    if (bleClientConnected) { flushBuffer(); delay(200); }
    BLEDevice::deinit(true);
  }

  esp_sleep_enable_timer_wakeup((uint64_t)SAMPLE_INTERVAL_S * 1000000ULL);
  Serial.flush();
  esp_deep_sleep_start();
}

void loop() { /* never reached */ }
estimateCore() with k · (T_skin − T_outer)The bracketed difference is proportional to outward heat flux through a layer of known resistance. Multiplying by a calibrated constant extrapolates back through the body's own thermal resistance to the core — which is the entire reason for the second sensor.
if (flux < 0) flux = 0A negative gradient means heat is flowing inward, which happens in a hot room or direct sun. Extrapolating downward in that case would report a core temperature below skin temperature, which is physically wrong.
checkAttached() two conditionsBoth must hold: the skin must be much warmer than the room, and a gradient must exist across the insulator. A detached patch fails both within about two minutes, and reporting "detached" rather than an ambient reading is the single most important safety behaviour here.
ringCount = 0 on detachA detached period makes the preceding trend meaningless. Clearing the buffer prevents a rate-of-rise alert firing purely because the patch was reattached to a warm body.
computeRate() least squares, not two-pointA DS18B20 quantises to 0.0625 °C. Over 30 minutes a real fever moves perhaps 0.5 °C, which is only eight quantisation steps — a two-point difference is dominated by that quantisation, while a least-squares fit over 60 samples is not.
wakeCount % RADIO_EVERY_NThe radio dominates power consumption. Sampling every 30 seconds but advertising every 5 minutes cuts average current by roughly four times, which is the difference between two days and eight days of battery.
IEEE-11073 float in bleSendThe standard BLE Health Thermometer characteristic uses a 32-bit medical float: a 24-bit signed mantissa and an 8-bit exponent. Using this standard format means any generic BLE thermometer app can read the patch with no custom code.

Configuration & Calibration

Configuration steps

  • Record both DS18B20 ROM addresses and assign them explicitly to skin and outer rather than relying on discovery order, which can change if a sensor is replaced.
  • Set kCalib by calibrating against a reference thermometer — the routine is in the code, and the value is typically between 0.6 and 1.4.
  • Set WARN_C and ALERT_C from clinical guidance for the wearer. 37.8 °C and 38.5 °C are common adult thresholds; paediatric thresholds differ and should come from a clinician.
  • Set RADIO_EVERY_N from your battery target. Every 10th wake (5 minutes) gives about a week; every wake gives about two days.
  • Choose the insulating layer deliberately and keep it consistent. Changing its thickness changes the effective thermal resistance and invalidates the calibration.

Calibration procedure

An uncalibrated sensor produces confident, precise, wrong numbers. Do this once per physical unit and record the constants.

  1. Calibrate k against a reference

    Wear the patch for 30 minutes to reach thermal equilibrium, then take a reading with a validated clinical thermometer at the same site. Call calibrateAgainst(reference) with that value. Repeat at a different ambient temperature and average the two k values — a single-point calibration is valid only near those conditions.

  2. Verify both sensors agree

    Hold both sensors together in a water bath at body temperature. They should read within 0.2 °C of each other. A larger difference is a sensor offset that must be corrected in software before the heat-flux calculation means anything, because it is subtracting one from the other.

  3. Check the equilibration time

    Attach the patch and log for an hour. The reading should stabilise within 15–25 minutes. Much longer means the thermal mass is too high — reduce the potting compound or use a thinner substrate.

  4. Validate the detach detection

    Remove the patch and watch. It should report detached within two minutes, not report an ambient temperature as if it were a body temperature.

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.

TestWhat you should see
Read both sensors in airBoth within 0.2 °C of each other and of the ambient reference.
Attach the patch under an armSkin temperature rises to 34–36 °C over 15–25 minutes; the attached flag becomes true within a few samples.
Compare the estimate with a clinical thermometerWithin about 0.3 °C after calibration, at similar ambient conditions.
Remove the patchDetached reported within two minutes, and no temperature published — not an ambient reading.
Move to a much colder room while wearing itThe gradient increases, the estimate holds within about 0.5 °C, and it does not collapse toward skin temperature.
Connect a generic BLE thermometer appThe Health Thermometer Service is discovered and readings appear with no custom software.
Leave BLE out of range for two hoursReadings buffer locally and flush on reconnection.
Measure average currentAround 0.7–1.0 mA with radio every 10th wake, giving roughly a week on 150 mAh.

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.

An electrocardiogram waveform trace
A physiological trace. The shape of a temperature curve overnight is information a spot check simply cannot provide. Photograph sourced from Wikimedia Commons — ECG trace.png. Reused under the licence stated on that page; please check it before republishing.

Troubleshooting: Common Errors & Fixes

Reported temperature is two degrees below a clinical thermometer

Likely cause. Raw skin temperature is being reported, or k is far too low.

Fix. Skin is genuinely 1.5–3 °C below core — that is physics, not a bug. Confirm the heat-flux term is being applied and calibrate k against a reference. If the gradient across the insulator is near zero, the insulating layer is too thin or the sensors are thermally shorted together.

The gradient between the two sensors is nearly zero

Likely cause. The sensors are thermally coupled — usually by a metal enclosure, a shared PCB copper pour, or potting compound bridging them.

Fix. Separate them with the intended insulator and nothing else. Cut a slot in the PCB between them if they share a board. Any conductive path between the two defeats the entire measurement.

Readings jump when the wearer moves

Likely cause. The patch is losing and regaining contact.

Fix. Use a better adhesive and a more flexible substrate. Under an arm is a high-movement site; behind the upper arm or on the chest is more stable. Also confirm the detach detection catches the loss rather than reporting a corrupted value.

Only one DS18B20 is found

Likely cause. Missing pull-up, a bad joint, or two sensors with the same address (rare but it happens with counterfeits).

Fix. Fit a 4.7 kΩ pull-up from data to 3.3 V. Run a 1-Wire address scan and confirm two distinct 64-bit ROM codes. If both report identically, one is a clone and must be replaced.

Battery lasts two days rather than a week

Likely cause. The radio is waking every sample.

Fix. Advertise every 10th wake and buffer in between. BLE advertising and connection dominate the power budget by roughly four to one over the sensor reads.

The rate-of-rise alert fires spuriously

Likely cause. A two-point difference on a quantised sensor, or a reattachment being read as a rise.

Fix. Use the least-squares fit over 30 minutes, and clear the trend buffer whenever the patch detaches. Reattaching to a warm body looks exactly like a rapid fever if the buffer is not cleared.

The sketch will not upload — "Failed to connect" or "avrdude: stk500_recv()"

Likely cause. The bootloader is not being reached: wrong port, wrong board, a serial monitor holding the port open, or a USB cable that only carries power.

Fix. Close every serial monitor, confirm Tools → Board and Port, and swap to a known data-capable USB cable. On an ESP32 hold BOOT while the IDE prints "Connecting…", then release. If a peripheral is wired to the UART pins (GPIO 1/3 on ESP32, D0/D1 on Uno) unplug it — it fights the programmer.

The board resets in a loop, or the serial monitor prints "Brownout detector was triggered"

Likely cause. The supply cannot deliver peak current. Wi-Fi transmit bursts, relay coils and servos all pull far more than their average draw.

Fix. Power peripherals from a separate regulated supply with a common ground rather than from the board 5 V pin. Add a 470–1000 µF electrolytic capacitor across the supply near the load, and use a real power adapter rather than a laptop USB port.

Serial monitor shows garbage characters

Likely cause. Baud rate mismatch between Serial.begin() and the monitor, or a floating/shared UART line.

Fix. Set the monitor to 115200 to match the sketch. If it still garbles, the crystal or the USB bridge is being confused by noise — shorten the cable and keep motor wiring away from the USB lead.

Performance Optimisation

  • Advertise the radio every 5 minutes and buffer readings between — it is the single largest power saving available and costs nothing in usefulness.
  • Use 12-bit resolution only if you need 0.0625 °C. Dropping to 11-bit halves the 750 ms conversion time and the energy with it.
  • Gate sensor power through a MOSFET. Two DS18B20s and an SHT31 left powered continuously is over 3 mA, which is several times the rest of the budget combined.
  • Replace every delay() with a millis() 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_t where 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

  • Not a clinical thermometer. Confirm any concerning reading with a validated device before acting on it, and see a doctor for anyone genuinely unwell — especially an infant.
  • Use hypoallergenic medical adhesive and check the skin daily. Days of continuous adhesive contact causes irritation, particularly on children and older adults.
  • Encapsulate all electronics in skin-safe silicone. Bare copper against skin corrodes in sweat and can cause a contact reaction.
  • Keep the lithium cell on the outer face and use a protected cell. A cell against skin for days is both a comfort and a thermal safety consideration.
  • Do not use on broken skin, and remove it before any MRI scan.
  • 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.

  • Upgrade to MAX30205 sensors, specified at ±0.1 °C in the 37–39 °C range, which is where accuracy actually matters for fever.
  • Add a zero-heat-flux heater for genuinely accurate core measurement, accepting the much larger battery it requires.
  • Add heart rate from a PPG sensor on the same patch — combined temperature and heart rate is far more informative clinically than either alone.
  • Add a phone app with a proper timeline and antipyretic dose markers, so the effect of each dose is directly visible.
  • Add multi-patient support for a ward or care home, with a single gateway aggregating many patches.
  • 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

Why not just use one sensor against the skin?

Because skin temperature is not body temperature. It sits 1.5–3 °C below core, and the offset changes with ambient temperature, blood flow and clothing. A single sensor with a fixed offset is accurate only under the exact conditions you calibrated it in, and it reports a plausible-looking wrong number outside them. The second sensor measures the gradient, which is what lets you correct for the conditions.

How accurate is the core estimate?

After per-wearer calibration, within about 0.3 °C under conditions similar to the calibration. It degrades when ambient temperature changes substantially, or when vasoconstriction changes — a shivering patient has very different core-to-skin resistance. Treat it as an accurate trend and a rough absolute.

Where should the patch go?

Under the arm (axilla) is the classic site and gives the smallest core offset. The chest and the upper back are more comfortable for long wear and more movement-stable, at the cost of a slightly larger offset — which the calibration absorbs. Forehead is worst: highly exposed to ambient and to evaporation.

Why does it take 20 minutes to stabilise?

Thermal mass. The patch, its potting and the adhesive all have to reach equilibrium with the skin, and the skin under the patch has to reach equilibrium with the reduced heat loss. Any reading in the first 15 minutes after attachment is transitional — the firmware should arguably suppress it entirely, as a clinical device would.

Can this replace a thermometer?

No. It is not validated, not calibrated to a traceable standard, and not regulated. What it does that a thermometer cannot is show you the shape of the curve overnight, which is genuinely useful information a spot check cannot provide. Use both.

Is BLE safe for continuous wear?

BLE transmits at about 1–10 mW, which is orders of magnitude below a mobile phone and far below any established thermal or non-thermal effect threshold. The bigger practical concern with continuous wear is adhesive irritation, not radio exposure.

References & Learning Resources

These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.

  1. DS18B20 programmable resolution 1-Wire digital thermometer — datasheetAnalog Devices
  2. MAX30205 human body temperature sensor — datasheetAnalog Devices
  3. Fox & Solman, "A new technique for monitoring the deep body temperature" (zero heat flux)The Journal of Physiology, 1971
  4. Sim et al., "Estimation of circadian body temperature rhythm based on heart rate"IEEE JBHI, 2017
  5. Bluetooth SIG — Health Thermometer Service specificationBluetooth SIG
  6. Niven et al., "Accuracy of peripheral thermometers for estimating temperature: a systematic review"Annals of Internal Medicine, 2015