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.
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
| Setting | How it is used |
|---|---|
| Paediatric fever monitoring | The primary case — overnight trajectory in a feverish child, without waking them every two hours. |
| Post-operative infection watch | A rising temperature is often the first sign of a surgical site infection, and continuous monitoring catches it a day earlier. |
| Elderly care | Older adults often mount a blunted fever response; a small but sustained rise is significant and easy to miss on spot checks. |
| Antipyretic effectiveness | Seeing exactly when and how much paracetamol brought a temperature down is genuinely informative. |
| Ovulation and cycle tracking | Basal body temperature shifts of about 0.3 °C are detectable with continuous overnight measurement. |
| Occupational heat stress | The 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
| Attribute | Value |
|---|---|
| Difficulty level | Intermediate |
| Estimated completion time | 10–14 hours |
| Indicative build cost | ₹2,400 – ₹3,400 |
| Primary discipline | Health & Wearables |
| Reference platform | ESP32 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.
| 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 |
| 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 ID | 2 | ₹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 heater | 1 | ₹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 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 |
| 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²C | 2 | ₹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 thickness | 1 | ₹180 |
| Hypoallergenic medical adhesive patches | 50 mm, breathable, 3M 1776 or similar | 20 | ₹6,400 |
| 150 mAh LiPo cell | 3.7 V, protected, 4 mm thick | 1 | ₹240 |
| Flexible PCB or thin perfboard + silicone potting | Skin-safe encapsulation | 1 | ₹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
| 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 |
| DS18B20 waterproof temperature probe | −55 to +125 °C, ±0.5 °C from −10 to +85 °C, 9–12-bit resolution, unique 64-bit ROM ID | 3.0–5.5 V | 1-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 heater | 2.4–5.5 V | I²C (0x44/0x45) | 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. |
| DS18B20 waterproof temperature probe | 3.0–5.5 V | 3 | Dozens can share one GPIO — you address them by ROM code. |
| SHT31-D temperature + humidity sensor | 2.4–5.5 V | 1.5 | The built-in heater burns off condensation — essential for outdoor or greenhouse use. |
| 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 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.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 |
|---|---|---|
| OneWire + DallasTemperature 2.3.x / 3.9.x | Bus enumeration and conversion commands for DS18B20 probes. | Library Manager → "DallasTemperature" (pulls OneWire) |
| Preferences (NVS) bundled | Wear-levelled key/value storage in ESP32 flash for settings. | Bundled with the ESP32 core |
| ArduinoJson 7.x | Zero-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.
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 |
|---|---|---|---|
| Skin-side temperature sensor | DATA / SDA | GPIO 27 | 1-Wire with 4.7 kΩ pull-up |
| Outer-side temperature sensor | DATA / SDA | GPIO 27 | Same bus, different ROM ID |
| SHT31 ambient reference | SDA / SCL | GPIO 21 / 22 | I²C at 0x44 |
| Battery divider | Mid-point | GPIO 34 | 1 MΩ / 1 MΩ, gated |
| Status LED | Anode | GPIO 25 | Very brief flashes only |
| Buzzer (optional) | + | GPIO 26 | Threshold alert |
| Sensor power gate | MOSFET gate | GPIO 14 | Cuts 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.
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
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
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
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
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.
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.
/* ═══════════════════════════════════════════════════════════════
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 */ }
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
kCalibby calibrating against a reference thermometer — the routine is in the code, and the value is typically between 0.6 and 1.4. - Set
WARN_CandALERT_Cfrom 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_Nfrom 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.
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.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.
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.
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.
| Test | What you should see |
|---|---|
| Read both sensors in air | Both within 0.2 °C of each other and of the ambient reference. |
| Attach the patch under an arm | Skin 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 thermometer | Within about 0.3 °C after calibration, at similar ambient conditions. |
| Remove the patch | Detached reported within two minutes, and no temperature published — not an ambient reading. |
| Move to a much colder room while wearing it | The gradient increases, the estimate holds within about 0.5 °C, and it does not collapse toward skin temperature. |
| Connect a generic BLE thermometer app | The Health Thermometer Service is discovered and readings appear with no custom software. |
| Leave BLE out of range for two hours | Readings buffer locally and flush on reconnection. |
| Measure average current | Around 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.
Troubleshooting: Common Errors & Fixes
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 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
- 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
References & Learning Resources
These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.
- DS18B20 programmable resolution 1-Wire digital thermometer — datasheetAnalog Devices
- MAX30205 human body temperature sensor — datasheetAnalog Devices
- Fox & Solman, "A new technique for monitoring the deep body temperature" (zero heat flux)The Journal of Physiology, 1971
- Sim et al., "Estimation of circadian body temperature rhythm based on heart rate"IEEE JBHI, 2017
- Bluetooth SIG — Health Thermometer Service specificationBluetooth SIG
- Niven et al., "Accuracy of peripheral thermometers for estimating temperature: a systematic review"Annals of Internal Medicine, 2015