Contents — 26 sections
Project Overview
A pendant that distinguishes an actual fall from sitting down heavily, gives the wearer thirty seconds to cancel, and then escalates to family with a location — running for months on one charge.
Fall detection is a classification problem with a brutally asymmetric cost function. A missed fall can mean hours on a floor; a false alarm at 2 a.m. means a frightened family and, after the third one, a device that gets taken off and left in a drawer. Almost every published fall-detection algorithm optimises sensitivity and quietly ignores what its false-positive rate does to real-world adoption.
This design uses the three-phase signature that distinguishes a genuine fall from every common false positive. A real fall has a free-fall phase where total acceleration drops well below 1 g, followed within a few hundred milliseconds by an impact spike, followed by a period of altered orientation and near-stillness. Sitting down heavily has the impact but no free-fall. Dropping the pendant has free-fall and impact but the orientation afterwards is random and it is picked up quickly. Requiring all three phases in sequence, with timing constraints, removes most false positives without sacrificing much sensitivity.
The other half of the design is the escalation protocol, which matters as much as the detection. On a detected fall the pendant vibrates and beeps for thirty seconds with a large cancel button — because most detected events are recoverable and the wearer is fine. Only on timeout does it alert, and then it alerts progressively: a family member first, then a second contact, then a broader group, each with the previous alert's outcome attached.
Power is the constraint that shapes everything else. A pendant that needs charging nightly gets left off the charger. The design targets months, which means the accelerometer's own interrupt engine does the watching while the microcontroller sleeps at microamps, waking only when something happens.
What this project does
- Detects falls using a three-phase free-fall, impact and post-impact orientation signature.
- Rejects sitting, lying down, dropping the device and normal walking through explicit timing and orientation tests.
- Gives a thirty-second cancel window with escalating vibration and audible prompts.
- Escalates through a contact list, attaching the outcome of each previous stage.
- Includes a manual SOS button that bypasses detection entirely.
- Reports approximate location from Wi-Fi access-point scanning without needing GPS.
- Runs for two to four months on a 500 mAh cell through hardware-interrupt wake.
Real-World Applications
| Setting | How it is used |
|---|---|
| Independent elderly living | The core use case, and the one where the false-positive rate determines whether the device is actually worn. |
| Epilepsy and syncope monitoring | The same signature detects a collapse from loss of consciousness. |
| Lone worker safety | Industrial man-down detection, often a regulatory requirement, with the same algorithm and a rugged enclosure. |
| Post-surgical recovery at home | A temporary safety net during the weeks when balance is impaired. |
| Care home monitoring | Many pendants, one gateway, with per-resident alerting. |
| Teaching sensor fusion and classification | A real problem where a naive threshold demonstrably fails and the reason why is physically intuitive. |
Deployment contexts where a build of this kind earns its keep.
Features & Capabilities
- Three-phase detection with timing constraints between phases, not a single acceleration threshold.
- ADXL345 activity and free-fall interrupts so the MCU sleeps at about 12 µA until something happens.
- Orientation change test using the gravity vector before and after impact.
- Post-impact stillness window — a person who gets straight up did not need help.
- Thirty-second cancel with a large, findable button and escalating haptics.
- Multi-stage escalation with per-contact acknowledgement.
- Wi-Fi RSSI fingerprint location, accurate to a room indoors, with no GPS power cost.
- Daily heartbeat with battery voltage, so a dead pendant is visibly dead.
Difficulty, Time & Required Skills
| Attribute | Value |
|---|---|
| Difficulty level | Intermediate |
| Estimated completion time | 12–18 hours |
| Indicative build cost | ₹2,200 – ₹2,900 |
| Primary discipline | Health & Wearables |
| Reference platform | ESP32 DevKit V1 (ESP-WROOM-32) |
Skills you should have (or will pick up)
- Arduino C++ with interrupt handling and deep sleep
- Understanding of accelerometer axes, gravity and dynamic acceleration
- Configuring sensor hardware interrupts through registers
- Simple state machines
- MQTT and notification services
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 |
| 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 |
| Active piezo buzzer 5 V Active buzzers make tone on DC; passive ones need a PWM carrier. | 85 dB at 10 cm, 2.3 kHz resonance, 12 mm diameter | 1 | ₹25 |
| 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 |
| Coin vibration motor + driver transistor | 10 mm, 3 V, 80 mA | 1 | ₹120 |
| Large tactile SOS / cancel button Must be findable by touch alone, by someone who has just fallen and may be disoriented. | 12 mm cap, NO, high travel | 1 | ₹60 |
| 3D-printed pendant shell + lanyard The lanyard must break away under load — a fixed cord around a neck is a strangulation risk. | PETG, break-away lanyard clasp | 1 | ₹180 |
| 500 mAh LiPo cell | 3.7 V, with protection circuit | 1 | ₹260 |
Estimated total: ₹1,870, 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 |
| 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 |
| Active piezo buzzer 5 V | 85 dB at 10 cm, 2.3 kHz resonance, 12 mm diameter | 3–5 V | Digital / PWM | 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. |
| 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. |
| Active piezo buzzer 5 V | 3–5 V | 30 | Active buzzers make tone on DC; passive ones need a PWM carrier. |
| 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 1190.1399999999999 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 |
|---|---|---|
| WiFi (ESP32 core) bundled | Station/AP connection management for the ESP32. | Bundled with the ESP32 Arduino core |
| PubSubClient 2.8 | Lightweight MQTT 3.1.1 client for constrained devices. | Library Manager → "PubSubClient" by Nick O'Leary |
| ArduinoJson 7.x | Zero-allocation JSON serialisation and parsing. | Library Manager → "ArduinoJson" by Benoit Blanchon |
| 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 |
|---|---|---|---|
| ADXL345 accelerometer | SDA / SCL | GPIO 21 / 22 | I²C at 0x53 |
| ADXL345 INT1 | INT1 | GPIO 33 | Free-fall / activity, RTC wake |
| SOS / cancel button | NO | GPIO 32 | Pull-up, also an RTC wake source |
| Battery divider | Mid-point | GPIO 34 | 1 MΩ / 1 MΩ, MOSFET gated |
| Vibration motor | Transistor base | GPIO 25 | Through 1 kΩ, with flyback |
| Piezo buzzer | + | GPIO 26 | LEDC PWM |
| Status LED | Anode | GPIO 27 | Brief flashes only |
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
- Wire INT1 to an RTC-capable GPIO (33 here) so it can wake the ESP32 from deep sleep via
ext0. A non-RTC pin means the pendant sleeps and never wakes, which is a silent and total failure. - Configure the ADXL345 interrupt as active high and wake on high. The default is active low, which conflicts with the pull-up arrangement used for the button on the same wake mechanism.
- The vibration motor is inductive and draws about 80 mA. Drive it through an NPN transistor or a small MOSFET with a flyback diode — a GPIO cannot supply it and the back-EMF will damage the pin.
- The lanyard clasp must break away under roughly 3 kg of load. A pendant on a fixed cord is a genuine strangulation hazard, particularly for the population this device is for.
- Mount the accelerometer rigidly to the shell. A sensor that can rattle inside the case produces impact spikes from ordinary handling.
- Use a bare ESP32 module rather than a development board. A DevKit draws 8–20 mA in deep sleep because of its regulator and USB chip, which turns three months of battery life into three days.
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
An accelerometer at rest measures 1 g — not zero. It senses the normal force opposing gravity, so a stationary sensor reads 9.81 m/s² along whichever axis points up. This is the key to the whole algorithm: during genuine free fall, nothing opposes gravity, so the measured total acceleration drops towards 0 g. A body falling towards the floor is in partial free fall for 200–400 ms, and total acceleration typically dips to 0.3–0.7 g. Nothing else in daily life produces that signature.
The impact follows. Deceleration on hitting the floor produces a spike of 2–6 g depending on the surface and how the person lands. On its own this is a poor discriminator — sitting down heavily on a chair produces 2–3 g, and setting the pendant down on a table produces more. It is only the sequence free-fall then impact, with under 800 ms between them, that is specific to a fall.
The third phase is what removes the remaining false positives. After a real fall the person is on the floor, so the pendant's orientation relative to gravity has changed substantially — typically by more than 30° — and they are largely still for at least a couple of seconds. Someone who sits down heavily has a small orientation change and continues to move normally. Someone who drops the pendant picks it up within seconds. Requiring sustained stillness in a changed orientation is the single most effective false-positive filter available.
Orientation is computed from the gravity vector: with the device roughly still, the measured acceleration vector points along "up" in the device frame. Comparing the unit vector before the event with the one after gives the angle change directly through the dot product. This works without a gyroscope and without any integration, which matters because integrating accelerometer data to get orientation drifts badly within seconds.
The power architecture uses the ADXL345's own interrupt engine. The sensor has hardware free-fall and activity detection with configurable thresholds and time windows, running from its own 40 µA supply. It watches continuously while the ESP32 sleeps at around 10 µA. The microcontroller only wakes when the accelerometer says something happened, which is a handful of times a day, and each wake costs a few hundred milliamp-milliseconds.
The maths behind it
Free-fall and impact thresholds
Total acceleration magnitude:
|a| = sqrt(ax² + ay² + az²)
At rest : |a| ≈ 1.00 g
Free fall : |a| → 0 g (in practice 0.3–0.7 g for a body)
Impact : |a| = 2–6 g
ADXL345 free-fall register settings:
THRESH_FF (0x28) = 0x08 → 8 × 62.5 mg = 0.50 g
TIME_FF (0x29) = 0x14 → 20 × 5 ms = 100 ms
Requiring 100 ms below 0.5 g rejects the brief dips
from ordinary arm swing, which last under 40 ms.
Orientation change from the gravity vector
Before: g₁ = (x₁, y₁, z₁) normalised
After : g₂ = (x₂, y₂, z₂) normalised
cos θ = g₁ · g₂ = x₁x₂ + y₁y₂ + z₁z₂
θ = acos(cos θ)
Example — pendant upright, then lying on its side:
g₁ = (0.02, 0.05, 0.998)
g₂ = (0.97, 0.11, 0.21)
cos θ = 0.019 + 0.006 + 0.210 = 0.235
θ = 76.4° → clearly a fall-consistent change
Sitting down typically gives θ under 20°.
Battery life
ADXL345 in measurement mode with interrupts: 40 µA
ESP32 deep sleep (bare module) : 10 µA
Total idle : 50 µA
Wakes: ~6 false triggers/day × 3 s at 90 mA
= 6 × 3 × 90 / 86400 = 18.75 µA average
Daily heartbeat: 1 × 8 s at 130 mA
= 8 × 130 / 86400 = 12.0 µA average
Total ≈ 81 µA
500 mAh cell, usable to 3.4 V ≈ 430 mAh
Life = 430 / 0.081 = 5309 h ≈ 7.4 months
Derate for self-discharge and cold: 3–5 months realistic.
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.
/* ═══════════════════════════════════════════════════════════════
Fall Detection Pendant — ESP32 + ADXL345
Three-phase detection: free fall, impact, then a sustained change
in orientation with stillness. The accelerometer's own interrupt
engine watches while the ESP32 sleeps at ~10 uA.
Not a medical device. A supplement to human care, never a
replacement for it.
══════════════════════════════════════════════════════════════════ */
#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <Wire.h>
#include <esp_sleep.h>
#include <driver/rtc_io.h>
#include <math.h>
#define WIFI_SSID "YOUR_WIFI"
#define WIFI_PASS "YOUR_PASSWORD"
#define MQTT_HOST "192.168.1.50"
#define DEVICE_ID "pendant-01"
#define ADXL_ADDR 0x53
#define PIN_INT GPIO_NUM_33
#define PIN_BTN GPIO_NUM_32
#define PIN_VIBE 25
#define PIN_BUZZ 26
#define PIN_LED 27
#define PIN_BATT 34
#define PIN_BATT_EN 14
#define FF_THRESH_G 0.60f
#define IMPACT_THRESH_G 2.50f
#define IMPACT_WINDOW_MS 800
#define ORIENT_CHANGE_DEG 30.0f
#define STILL_THRESH_G 0.18f
#define STILL_WINDOW_MS 2000
#define CANCEL_WINDOW_S 30
#define HEARTBEAT_S 86400ULL
RTC_DATA_ATTR uint32_t bootCount = 0, fallCount = 0, cancelCount = 0;
WiFiClient net;
PubSubClient mqtt(net);
volatile bool acknowledged = false;
/* ── ADXL345 ────────────────────────────────────────────────── */
void adxlWrite(uint8_t reg, uint8_t val) {
Wire.beginTransmission(ADXL_ADDR);
Wire.write(reg); Wire.write(val);
Wire.endTransmission();
}
uint8_t adxlRead(uint8_t reg) {
Wire.beginTransmission(ADXL_ADDR);
Wire.write(reg);
Wire.endTransmission(false);
Wire.requestFrom(ADXL_ADDR, 1);
return Wire.read();
}
void adxlReadXYZ(float &x, float &y, float &z) {
Wire.beginTransmission(ADXL_ADDR);
Wire.write(0x32); // DATAX0
Wire.endTransmission(false);
Wire.requestFrom(ADXL_ADDR, 6);
int16_t rx = Wire.read() | (Wire.read() << 8);
int16_t ry = Wire.read() | (Wire.read() << 8);
int16_t rz = Wire.read() | (Wire.read() << 8);
x = rx * 0.0039f; y = ry * 0.0039f; z = rz * 0.0039f; // 3.9 mg/LSB
}
void adxlBegin() {
adxlWrite(0x2D, 0x00); // standby while configuring
adxlWrite(0x31, 0x08); // full resolution, +/-2 g
adxlWrite(0x2C, 0x0A); // 100 Hz output data rate
// Hardware free-fall detection does the watching while we sleep.
adxlWrite(0x28, 0x09); // THRESH_FF: 9 x 62.5 mg = 0.56 g
adxlWrite(0x29, 0x14); // TIME_FF: 20 x 5 ms = 100 ms
adxlWrite(0x2E, 0x04); // INT_ENABLE: FREE_FALL only
adxlWrite(0x2F, 0x00); // map all interrupts to INT1
adxlWrite(0x31, adxlRead(0x31) & ~0x20); // INT active HIGH
adxlWrite(0x2D, 0x08); // measurement mode
adxlRead(0x30); // clear any pending interrupt
}
/* ── feedback ───────────────────────────────────────────────── */
void vibe(uint16_t ms) { digitalWrite(PIN_VIBE, HIGH); delay(ms); digitalWrite(PIN_VIBE, LOW); }
void alertPattern(uint8_t intensity) {
// Escalates as the cancel window runs down.
for (uint8_t i = 0; i <= intensity; i++) {
tone(PIN_BUZZ, 2000 + intensity * 300, 150);
vibe(160);
delay(120);
}
}
/* ── battery ────────────────────────────────────────────────── */
float batteryVolts() {
pinMode(PIN_BATT_EN, OUTPUT);
digitalWrite(PIN_BATT_EN, HIGH);
delay(5);
uint32_t acc = 0;
for (int i = 0; i < 16; i++) { acc += analogRead(PIN_BATT); delay(2); }
digitalWrite(PIN_BATT_EN, LOW);
return (acc / 16.0f / 4095.0f) * 3.3f * 2.0f * 1.04f;
}
/* ── the three-phase classifier ─────────────────────────────── */
struct FallResult {
bool isFall;
float peakG, orientationDeg;
uint16_t impactDelayMs;
const char *rejectedBecause;
};
FallResult classify() {
FallResult r = { false, 0, 0, 0, nullptr };
float x, y, z;
adxlReadXYZ(x, y, z);
float mag = sqrtf(x * x + y * y + z * z);
// --- Phase 1: confirm the free fall that woke us ---------------
uint32_t t0 = millis();
bool sawFreeFall = false;
while (millis() - t0 < 400) {
adxlReadXYZ(x, y, z);
mag = sqrtf(x * x + y * y + z * z);
if (mag < FF_THRESH_G) { sawFreeFall = true; break; }
delay(5);
}
if (!sawFreeFall) { r.rejectedBecause = "no-freefall"; return r; }
// --- Phase 2: impact within the window -------------------------
uint32_t ffEnd = millis();
float g1x = 0, g1y = 0, g1z = 0;
while (millis() - ffEnd < IMPACT_WINDOW_MS) {
adxlReadXYZ(x, y, z);
mag = sqrtf(x * x + y * y + z * z);
if (mag > r.peakG) { r.peakG = mag; }
if (mag > IMPACT_THRESH_G) { r.impactDelayMs = millis() - ffEnd; break; }
delay(5);
}
if (r.peakG < IMPACT_THRESH_G) { r.rejectedBecause = "no-impact"; return r; }
delay(300); // let the ringing settle
adxlReadXYZ(g1x, g1y, g1z); // orientation just after impact
// --- Phase 3: stillness in a changed orientation ---------------
uint32_t stillStart = millis();
float sumX = 0, sumY = 0, sumZ = 0;
int n = 0;
bool moved = false;
while (millis() - stillStart < STILL_WINDOW_MS) {
adxlReadXYZ(x, y, z);
mag = sqrtf(x * x + y * y + z * z);
if (fabsf(mag - 1.0f) > STILL_THRESH_G) { moved = true; break; }
sumX += x; sumY += y; sumZ += z; n++;
delay(20);
}
if (moved || n < 40) { r.rejectedBecause = "recovered"; return r; }
// Orientation change: dot product of normalised gravity vectors.
float ax = sumX / n, ay = sumY / n, az = sumZ / n;
float na = sqrtf(ax * ax + ay * ay + az * az);
// Reference "upright" orientation captured at the last calm heartbeat.
static float refX = 0, refY = 0, refZ = 1;
float dot = (ax * refX + ay * refY + az * refZ) / (na > 0 ? na : 1);
dot = dot > 1 ? 1 : (dot < -1 ? -1 : dot);
r.orientationDeg = acosf(dot) * 180.0f / (float)M_PI;
if (r.orientationDeg < ORIENT_CHANGE_DEG) {
r.rejectedBecause = "no-orientation-change";
return r;
}
r.isFall = true;
return r;
}
/* ── location from Wi-Fi access points ──────────────────────── */
String scanLocation() {
int n = WiFi.scanNetworks(false, false, false, 200);
String out = "[";
for (int i = 0; i < n && i < 5; i++) {
if (i) out += ",";
out += "{\"bssid\":\"" + WiFi.BSSIDstr(i) + "\",\"rssi\":" + String(WiFi.RSSI(i)) + "}";
}
WiFi.scanDelete();
return out + "]";
}
/* ── alerting ───────────────────────────────────────────────── */
void onMessage(char *topic, byte *payload, unsigned int len) {
if (len >= 3 && !strncmp((char *)payload, "ACK", 3)) acknowledged = true;
}
bool netUp() {
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) delay(250);
if (WiFi.status() != WL_CONNECTED) return false;
mqtt.setServer(MQTT_HOST, 1883);
mqtt.setCallback(onMessage);
mqtt.setBufferSize(1024);
if (!mqtt.connect(DEVICE_ID)) return false;
mqtt.subscribe("care/" DEVICE_ID "/cmd");
return true;
}
void publishAlert(const char *event, const FallResult &r, uint8_t stage) {
JsonDocument d;
d["device"] = DEVICE_ID;
d["event"] = event;
d["stage"] = stage;
d["peak_g"] = roundf(r.peakG * 100) / 100.0f;
d["orient_deg"] = roundf(r.orientationDeg);
d["impact_ms"] = r.impactDelayMs;
d["batt_v"] = roundf(batteryVolts() * 100) / 100.0f;
d["falls"] = fallCount;
d["cancels"]= cancelCount;
char buf[400];
size_t n = serializeJson(d, buf, sizeof(buf));
mqtt.publish("care/" DEVICE_ID "/alert", (uint8_t *)buf, n, true);
String loc = scanLocation();
mqtt.publish("care/" DEVICE_ID "/location", loc.c_str(), true);
}
/* Returns true if the wearer cancelled. */
bool cancelWindow() {
uint32_t start = millis();
uint8_t intensity = 0;
while (millis() - start < CANCEL_WINDOW_S * 1000UL) {
if (digitalRead(PIN_BTN) == LOW) {
vibe(400);
tone(PIN_BUZZ, 1200, 300);
return true;
}
// Escalate every 6 s so it becomes progressively harder to ignore.
uint8_t want = (millis() - start) / 6000;
if (want != intensity) intensity = want;
alertPattern(intensity);
delay(400);
}
return false;
}
/* ── sleep ──────────────────────────────────────────────────── */
void sleepNow() {
adxlRead(0x30); // clear latched interrupt
rtc_gpio_pullup_dis(PIN_INT);
rtc_gpio_pulldown_en(PIN_INT);
esp_sleep_enable_ext0_wakeup(PIN_INT, 1); // wake on HIGH
esp_sleep_enable_timer_wakeup(HEARTBEAT_S * 1000000ULL);
Serial.flush();
esp_deep_sleep_start();
}
/* ── setup runs once per wake ───────────────────────────────── */
void setup() {
Serial.begin(115200);
pinMode(PIN_VIBE, OUTPUT); pinMode(PIN_LED, OUTPUT);
pinMode((int)PIN_BTN, INPUT_PULLUP);
bootCount++;
Wire.begin(21, 22);
adxlBegin();
esp_sleep_wakeup_cause_t why = esp_sleep_get_wakeup_cause();
/* Manual SOS: the button bypasses detection entirely. */
if (digitalRead((int)PIN_BTN) == LOW) {
FallResult manual = { true, 0, 0, 0, nullptr };
vibe(600);
if (netUp()) publishAlert("manual-sos", manual, 1);
for (int i = 0; i < 20 && !acknowledged; i++) { alertPattern(2); mqtt.loop(); delay(500); }
sleepNow();
}
/* Timer wake: heartbeat only. */
if (why != ESP_SLEEP_WAKEUP_EXT0) {
FallResult idle = {};
if (netUp()) { publishAlert("heartbeat", idle, 0); mqtt.loop(); }
delay(200);
sleepNow();
}
/* Interrupt wake: run the classifier. */
digitalWrite(PIN_LED, HIGH);
FallResult r = classify();
digitalWrite(PIN_LED, LOW);
if (!r.isFall) {
Serial.printf("rejected: %s (peak %.2f g)\n", r.rejectedBecause, r.peakG);
sleepNow();
}
fallCount++;
Serial.printf("FALL: peak %.2f g, orientation %.0f deg, impact +%u ms\n",
r.peakG, r.orientationDeg, r.impactDelayMs);
if (cancelWindow()) {
cancelCount++;
if (netUp()) { publishAlert("cancelled", r, 0); mqtt.loop(); }
sleepNow();
}
/* No cancel — escalate. */
bool online = netUp();
for (uint8_t stage = 1; stage <= 3 && !acknowledged; stage++) {
if (online) publishAlert("fall", r, stage);
uint32_t t0 = millis();
while (millis() - t0 < 60000UL && !acknowledged) {
alertPattern(3);
if (online) mqtt.loop();
if (digitalRead((int)PIN_BTN) == LOW) { acknowledged = true; break; }
delay(600);
}
}
if (online) { publishAlert(acknowledged ? "acknowledged" : "unanswered", r, 4); mqtt.loop(); }
sleepNow();
}
void loop() { /* never reached */ }
Configuration & Calibration
Configuration steps
- Tune
FF_THRESH_Gand the ADXL345THRESH_FFregister together. Lower catches gentler falls and admits more false wakes; 0.5–0.6 g is the usual compromise. - Set
IMPACT_THRESH_Gfor the flooring. Carpet absorbs impact and may need 2.0 g; hard tile produces 4–6 g and tolerates a higher threshold. - Set
CANCEL_WINDOW_S. Thirty seconds is long enough for a recovered wearer to react and short enough that a real emergency is not delayed. - Configure the escalation contacts in your Node-RED or Home Assistant flow, not in firmware. The contact list changes far more often than the firmware does.
- Capture the reference upright orientation during a calm heartbeat, and re-capture it if the wearer changes how the pendant hangs.
Calibration procedure
An uncalibrated sensor produces confident, precise, wrong numbers. Do this once per physical unit and record the constants.
Collect real fall data safely
Drop the pendant onto a thick mattress from chest height, in several orientations, twenty times. Log peak g, free-fall duration and orientation change for each. That distribution tells you where your thresholds should sit far better than any published figure.
Collect the false-positive set
Wear it for a normal day, then deliberately sit down heavily twenty times, lie down on a bed twenty times, and set it on a table twenty times. Every one of these must be rejected. If any pass, note which phase failed to reject them and tighten that phase.
Verify sleep current
Break the battery positive lead and measure in series. You should see 50–90 µA. Above 500 µA means something is still powered — usually a dev board regulator or a permanently connected divider.
Test the escalation end to end
Trigger a real alert and confirm the notification actually reaches the phone, with the location attached, and that acknowledging it stops the pendant. Do this before the device is relied upon, and repeat quarterly.
Network Architecture & Connectivity
Communication protocol
Alerts are published retained so a phone that comes online after the event still sees it. Escalation staging lives in Node-RED rather than firmware, because contact lists and schedules change often and reflashing a pendant on someone's neck is not practical.
The pendant only joins Wi-Fi when it has something to report. Association costs two to four seconds and a few hundred milliamp-milliseconds, which is acceptable a handful of times a day and unaffordable continuously.
| Topic / endpoint | Direction | Payload |
|---|---|---|
care/pendant-01/alert | device → broker (retained) | JSON: event, stage, peak_g, orient_deg, impact_ms, batt_v, falls, cancels |
care/pendant-01/location | device → broker (retained) | JSON array of nearby BSSIDs with RSSI |
care/pendant-01/cmd | broker → device | "ACK" to stop escalation |
Message contract between the device and the broker.
Mobile app integration
A Node-RED flow subscribed to the alert topic drives the escalation: stage 1 notifies the primary contact with an urgent-priority ntfy push, waits 60 seconds for an acknowledgement, then stage 2 adds a second contact, then stage 3 notifies everyone. Each notification carries the peak g, the orientation change and the nearest access points.
Add a missed-heartbeat alarm. If a pendant has not reported in 36 hours it is flat, out of range or broken — and a silent pendant is the most dangerous failure this device has.
Security considerations
- Use broker authentication. An unauthenticated ACK topic means anyone on the network can silence a real emergency alert.
- The location data reveals when someone is home and where in the house. Keep it on a local broker, not a cloud service.
- Make the acknowledgement require a deliberate action in the notification, not a swipe-away — an alert dismissed by accident is worse than one that keeps ringing.
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 |
|---|---|
| Measure sleep current | 50–90 µA. Above 500 µA and the battery target is unreachable. |
| Drop the pendant onto a mattress from chest height | Wake, classify as a fall, and enter the cancel window within about three seconds. |
| Sit down heavily wearing it, twenty times | Zero alerts. Every one should be rejected as no-freefall. |
| Set it down firmly on a table, twenty times | Zero alerts, rejected as recovered or no-orientation-change. |
| Lie down on a bed slowly | Rejected as no-impact — a controlled descent has no impact spike. |
| Trigger a fall and press cancel | Alerts stop immediately, a cancelled event is published, and the pendant returns to sleep. |
| Trigger a fall and do not cancel | Escalation through three stages over about three minutes, with the phone notification arriving at stage 1. |
| Press the SOS button while idle | Immediate alert with no classification, which is the point of the manual path. |
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
- Let the accelerometer do the watching. Its hardware free-fall detection at 40 µA replaces continuous 100 Hz sampling on the MCU, which is the entire reason this runs for months.
- Cache the Wi-Fi channel and BSSID in RTC memory so an alert connects in about one second instead of four.
- Scan access points rather than acquiring GPS. A GPS cold fix costs 30 seconds and hundreds of milliamps and does not work indoors, which is where most falls happen.
- 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. - Batch several samples into one MQTT publish. Radio time, not CPU time, dominates the energy budget.
- Set the MQTT keep-alive to a value that matches your reporting interval so the broker does not churn reconnections.
- 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 not a medical device and must never be the only safety measure. No accelerometer algorithm catches every fall — a slow slide down a wall produces neither free fall nor impact.
- The lanyard must break away under load. A fixed cord around the neck of a person who has fallen is a strangulation hazard, and this population is exactly the one at risk.
- Test the full escalation chain monthly, including that the notification arrives and can be acknowledged. A pendant that alerts into a broken flow is worse than none, because everyone believes it is working.
- Never disable the manual SOS path to save power or code. It is the function that works when the classifier does not.
- 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
- Charge when a heartbeat reports below 3.5 V; do not wait for the device to die.
- Test a real fall and a real escalation quarterly, with the intended recipients.
- Check the lanyard break-away clasp still releases under load every few months — they stiffen with wear.
- 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.
- Keep the broker and dashboard containers patched, and rotate device credentials at least once a year.
- 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 gyroscope (upgrade to an MPU-6050 or IMU with sensor fusion). Angular velocity during a fall is a strong additional feature and improves discrimination of the slow-slide case.
- Add a small on-device classifier trained on your own labelled fall and non-fall data. A decision tree with ten features outperforms hand-tuned thresholds and still runs in kilobytes.
- Add LoRa as a fallback transport so an alert works outside Wi-Fi range, in a garden or a stairwell.
- Add a speaker and two-way audio so a responder can talk to the wearer immediately, which is what commercial systems do and what most reassures families.
- Add barometric altitude — a rapid pressure increase of a few pascals is a direct measurement of descending, and it disambiguates a fall from a fall down stairs.
- 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 over-the-air firmware updates so you never have to physically reach a deployed node again.
- 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.
- ADXL345 3-axis digital accelerometer — datasheet with free-fall and activity registersAnalog Devices
- Bourke et al., "Evaluation of a threshold-based tri-axial accelerometer fall detection algorithm"Gait & Posture, 2007
- Noury et al., "Fall detection — Principles and Methods"IEEE EMBS, 2007
- Chaudhuri et al., "Fall detection devices and their use with older adults: a systematic review"Journal of Geriatric Physical Therapy
- ESP32 deep sleep and ext0 wake sourcesEspressif
- WHO — Falls fact sheetWorld Health Organization