/*
* Smart home intrusion & risk detection — sensor node (step 1)
* ESP32 + PIR + HC-SR04 + DHT22 + MQ-2 + buzzer + LCD I2C, simulated in Wokwi.
*
* What it does:
* - Samples motion, distance and gas at 5 Hz, temperature/humidity every 2 s.
* - Keeps the last 5 s in a ring buffer. When PIR fires or something approaches,
* it publishes one "event" message = 5 s before + 10 s after (raw series for the AI).
* - Local fire rule: gas high for 3 s, or fast/absolute temperature rise -> siren,
* with no network needed. Publishes a retained alarm message when online.
* - Periodic telemetry, online/offline status (Last Will), remote commands.
*/
#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <DHTesp.h>
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <time.h>
// ---------------- Config ----------------
#define DEVICE_ID "node1"
// The public broker is shared by everyone: change this to your own random string.
#define TOPIC_ROOT "ids-pepsi-man"
const char* WIFI_SSID = "Wokwi-GUEST";
const char* WIFI_PASS = "";
const char* MQTT_HOST = "broker.hivemq.com"; // step 1: public test broker (no TLS)
const uint16_t MQTT_PORT = 1883;
// Pins
const int PIN_PIR = 27;
const int PIN_TRIG = 5;
const int PIN_ECHO = 18;
const int PIN_DHT = 15;
const int PIN_GAS_A = 34; // ADC1 only (ADC2 is unusable with Wi-Fi on real hardware)
const int PIN_GAS_D = 35;
const int PIN_BUZZER = 25;
const int PIN_LED = 2;
const int PIN_BTN = 4;
// Gas: the clean-air level is measured at boot; the alarm is a rise above it.
int GAS_DELTA_RAW = 800; // alarm when gas exceeds the clean-air baseline by this (0..4095 scale)
const int GAS_ALARM_CAP = 4000; // never put the threshold above this (ADC saturates at 4095)
const uint32_t GAS_WARMUP_MS = 10000; // MQ-2 heater warm-up: gas rule ignored right after boot
// Clean-air baseline tracking: follows a cleaner reading at once, drifts up very slowly
// (time constant ~17 min at 5 Hz) so slow sensor drift is absorbed but a fire rise is not.
const float GAS_DRIFT_ALPHA = 0.0002f;
float TEMP_RISE_ALARM = 8.0; // degC per minute
float TEMP_ABS_ALARM = 57.0; // degC
const float APPROACH_RATIO = 0.6; // distance < 60% of the empty-room baseline = approach
// Sampling / event window
const uint32_t SAMPLE_MS = 200; // 5 Hz
const int PRE_SAMPLES = 25; // 5 s before trigger
const int POST_SAMPLES = 50; // 10 s after trigger
const int WIN_MAX = PRE_SAMPLES + POST_SAMPLES;
const uint32_t EVENT_COOLDOWN_MS = 3000;
const uint32_t TELEMETRY_MS = 30000;
// ---------------- State ----------------
struct Sample { uint16_t dist; uint8_t pir; uint16_t gas; };
Sample ring[PRE_SAMPLES];
int ringIdx = 0;
bool ringFull = false;
Sample win[WIN_MAX];
int winLen = 0, winPre = 0, postLeft = 0;
bool capturing = false;
String triggerReason;
uint32_t eventCount = 0, lastEventEnd = 0;
float baselineCm = 300;
float gasBaseline = 0;
int gasAlarmRaw = 2500;
float distCm = 300, prevDistCm = 300;
int pir = 0, prevPir = 0;
int gasRaw = 0;
float tempC = NAN, humPct = NAN;
// temperature history: one point every 10 s, last 60 s
const int TH_N = 7;
float tHist[TH_N];
uint32_t tHistMs[TH_N];
int tHistCount = 0;
float tempRisePerMin = 0;
bool armed = false;
bool fireAlarm = false;
bool sirenRemote = false;
uint32_t gasHighSince = 0, fireClearSince = 0;
uint32_t lastSample = 0, lastDht = 0, lastTHist = 0, lastTelemetry = 0, lastLcd = 0;
uint32_t lastWifiTry = 0, lastMqttTry = 0, lastBtnChange = 0;
int lastBtn = HIGH;
float lastSentTemp = NAN; int lastSentGas = 0;
DHTesp dht;
LiquidCrystal_I2C lcd(0x27, 16, 2);
WiFiClient net;
PubSubClient mqtt(net);
// ---------------- Helpers ----------------
String topic(const char* sub) { return String(TOPIC_ROOT) + "/" + DEVICE_ID + "/" + sub; }
time_t epochNow() { time_t t = time(nullptr); return t > 1600000000 ? t : 0; }
float readDistanceCm() {
digitalWrite(PIN_TRIG, LOW); delayMicroseconds(2);
digitalWrite(PIN_TRIG, HIGH); delayMicroseconds(10);
digitalWrite(PIN_TRIG, LOW);
unsigned long us = pulseIn(PIN_ECHO, HIGH, 30000UL); // ~5 m max
if (us == 0) return 400;
return us / 58.0f;
}
int readGasRaw() {
long sum = 0;
for (int i = 0; i < 8; i++) sum += analogRead(PIN_GAS_A);
return sum / 8;
}
void updateGasBaseline() {
if (gasBaseline <= 0 || gasRaw < gasBaseline) {
gasBaseline = gasRaw; // cleaner air than we thought: follow at once
} else if (!fireAlarm && gasRaw < gasAlarmRaw) {
gasBaseline += (gasRaw - gasBaseline) * GAS_DRIFT_ALPHA; // slow upward drift, frozen during alarms
}
gasAlarmRaw = min((int)gasBaseline + GAS_DELTA_RAW, GAS_ALARM_CAP);
}
void calibrateBaseline() {
float s = 0;
for (int i = 0; i < 10; i++) { s += readDistanceCm(); delay(60); }
baselineCm = s / 10;
Serial.printf("[cal] distance baseline = %.1f cm\n", baselineCm);
long g = 0;
for (int i = 0; i < 10; i++) { g += readGasRaw(); delay(20); }
gasBaseline = g / 10.0f;
gasAlarmRaw = min((int)gasBaseline + GAS_DELTA_RAW, GAS_ALARM_CAP);
Serial.printf("[cal] gas clean-air = %.0f, alarm above %d\n", gasBaseline, gasAlarmRaw);
if (gasBaseline > GAS_ALARM_CAP - 200)
Serial.println("[cal] WARNING: gas reading near ADC maximum - check MQ-2 wiring/voltage");
}
// ---------------- MQTT ----------------
void publishStatus() {
if (!mqtt.connected()) return;
JsonDocument d;
d["online"] = true; d["armed"] = armed; d["fire"] = fireAlarm;
d["baseline_cm"] = baselineCm; d["rssi"] = WiFi.RSSI();
d["gas_baseline"] = gasBaseline; d["gas_alarm"] = gasAlarmRaw;
char buf[192]; size_t n = serializeJson(d, buf);
mqtt.publish(topic("status").c_str(), (const uint8_t*)buf, n, true);
}
void publishAlarm(bool active) {
if (!mqtt.connected()) return;
JsonDocument d;
d["type"] = "fire"; d["active"] = active; d["ts"] = epochNow();
d["gas"] = gasRaw; d["temp"] = tempC; d["temp_rise_per_min"] = tempRisePerMin;
char buf[192]; size_t n = serializeJson(d, buf);
mqtt.publish(topic("alarm").c_str(), (const uint8_t*)buf, n, true);
}
void publishTelemetry() {
if (!mqtt.connected()) return;
JsonDocument d;
d["ts"] = epochNow(); d["pir"] = pir; d["dist"] = distCm; d["gas"] = gasRaw;
if (!isnan(tempC)) { d["temp"] = tempC; d["hum"] = humPct; }
d["armed"] = armed; d["fire"] = fireAlarm;
d["gas_baseline"] = (int)gasBaseline; d["gas_alarm"] = gasAlarmRaw;
char buf[256]; size_t n = serializeJson(d, buf);
mqtt.publish(topic("telemetry").c_str(), (const uint8_t*)buf, n, false);
lastSentTemp = tempC; lastSentGas = gasRaw; lastTelemetry = millis();
Serial.printf("[tel] gas=%d (clean %.0f, alarm>%d) dist=%.0f temp=%.1f pir=%d\n",
gasRaw, gasBaseline, gasAlarmRaw, distCm, tempC, pir);
}
void publishEvent() {
JsonDocument d;
d["id"] = eventCount; d["ts"] = epochNow(); d["trigger"] = triggerReason;
d["armed"] = armed; d["baseline_cm"] = baselineCm;
d["period_ms"] = SAMPLE_MS; d["pre"] = winPre;
if (!isnan(tempC)) { d["temp"] = tempC; d["hum"] = humPct; d["temp_rise_per_min"] = tempRisePerMin; }
JsonArray a = d["dist"].to<JsonArray>();
JsonArray b = d["pir"].to<JsonArray>();
JsonArray c = d["gas"].to<JsonArray>();
for (int i = 0; i < winLen; i++) { a.add(win[i].dist); b.add(win[i].pir); c.add(win[i].gas); }
static char buf[2048];
size_t n = serializeJson(d, buf, sizeof(buf));
bool ok = mqtt.connected() && mqtt.publish(topic("event").c_str(), (const uint8_t*)buf, n, false);
Serial.printf("[event %lu] %s, %d samples, %u bytes, %s\n",
(unsigned long)eventCount, triggerReason.c_str(), winLen, (unsigned)n, ok ? "sent" : "NOT sent (offline)");
}
void onMessage(char* t, byte* p, unsigned int len) {
String tp(t), msg;
for (unsigned int i = 0; i < len; i++) msg += (char)p[i];
msg.trim();
Serial.printf("[cmd] %s = %s\n", t, msg.c_str());
if (tp.endsWith("/cmd/siren")) sirenRemote = (msg == "on");
else if (tp.endsWith("/cmd/arm")) { armed = (msg == "on"); publishStatus(); }
else if (tp.endsWith("/cmd/calibrate")) { calibrateBaseline(); publishStatus(); }
else if (tp.endsWith("/cmd/gas_threshold")) { // value = rise above clean air
GAS_DELTA_RAW = msg.toInt();
gasAlarmRaw = min((int)gasBaseline + GAS_DELTA_RAW, GAS_ALARM_CAP);
publishStatus();
}
}
void maintainNetwork() {
uint32_t now = millis();
if (WiFi.status() != WL_CONNECTED) {
if (now - lastWifiTry > 5000) { lastWifiTry = now; WiFi.reconnect(); }
return; // sensing and the fire rule keep running without network
}
if (!mqtt.connected() && now - lastMqttTry > 3000) {
lastMqttTry = now;
String cid = String(DEVICE_ID) + "-" + String((uint32_t)ESP.getEfuseMac(), HEX);
String will = topic("status");
if (mqtt.connect(cid.c_str(), nullptr, nullptr, will.c_str(), 1, true, "{\"online\":false}")) {
Serial.println("[mqtt] connected");
mqtt.subscribe(topic("cmd/#").c_str(), 1);
publishStatus();
if (fireAlarm) publishAlarm(true);
} else {
Serial.printf("[mqtt] connect failed, rc=%d\n", mqtt.state());
}
}
mqtt.loop();
}
// ---------------- Sensing ----------------
void pushRing(const Sample& s) {
ring[ringIdx] = s;
ringIdx = (ringIdx + 1) % PRE_SAMPLES;
if (ringIdx == 0) ringFull = true;
}
void startCapture(const String& reason) {
capturing = true; triggerReason = reason; eventCount++;
winLen = 0;
int count = ringFull ? PRE_SAMPLES : ringIdx;
int start = ringFull ? ringIdx : 0;
for (int i = 0; i < count; i++) win[winLen++] = ring[(start + i) % PRE_SAMPLES];
winPre = winLen;
postLeft = POST_SAMPLES;
digitalWrite(PIN_LED, HIGH);
}
void sampleFast() {
prevPir = pir; prevDistCm = distCm;
pir = digitalRead(PIN_PIR);
distCm = readDistanceCm();
gasRaw = readGasRaw();
updateGasBaseline();
Sample s = { (uint16_t)constrain(distCm, 0, 999), (uint8_t)pir, (uint16_t)gasRaw };
if (capturing) {
win[winLen++] = s;
if (--postLeft <= 0) {
capturing = false; lastEventEnd = millis();
digitalWrite(PIN_LED, LOW);
publishEvent();
}
} else if (millis() - lastEventEnd > EVENT_COOLDOWN_MS) {
float lim = baselineCm * APPROACH_RATIO;
bool pirRise = pir && !prevPir;
bool approach = distCm < lim && prevDistCm >= lim;
if (pirRise || approach) startCapture(pirRise && approach ? "pir+approach" : pirRise ? "pir" : "approach");
}
pushRing(s);
}
void sampleDht() {
TempAndHumidity th = dht.getTempAndHumidity();
if (dht.getStatus() != DHTesp::ERROR_NONE) return;
tempC = th.temperature; humPct = th.humidity;
}
void updateTempHistory() {
if (isnan(tempC)) return;
if (tHistCount == TH_N) {
for (int i = 1; i < TH_N; i++) { tHist[i - 1] = tHist[i]; tHistMs[i - 1] = tHistMs[i]; }
tHistCount--;
}
tHist[tHistCount] = tempC; tHistMs[tHistCount] = millis(); tHistCount++;
if (tHistCount >= 2) {
float dtMin = (tHistMs[tHistCount - 1] - tHistMs[0]) / 60000.0f;
tempRisePerMin = dtMin > 0 ? (tHist[tHistCount - 1] - tHist[0]) / dtMin : 0;
}
}
// Local fire rule: never depends on Wi-Fi, the gateway or the AI.
void fireRule() {
uint32_t now = millis();
bool warm = now > GAS_WARMUP_MS;
if (warm && gasRaw > gasAlarmRaw) { if (!gasHighSince) gasHighSince = now; }
else gasHighSince = 0;
bool gasFire = gasHighSince && now - gasHighSince > 3000;
bool heatFire = (!isnan(tempC) && tempC > TEMP_ABS_ALARM) || tempRisePerMin > TEMP_RISE_ALARM;
bool danger = gasFire || heatFire;
if (danger && !fireAlarm) {
fireAlarm = true; fireClearSince = 0;
Serial.printf("[FIRE] %s: gas=%d (alarm>%d) temp=%.1f rise=%.1f/min\n",
gasFire ? "gas" : "heat", gasRaw, gasAlarmRaw, tempC, tempRisePerMin);
publishAlarm(true); publishStatus();
} else if (!danger && fireAlarm) {
if (!fireClearSince) fireClearSince = now ? now : 1;
if (now - fireClearSince > 10000) {
fireAlarm = false;
Serial.println("[FIRE] cleared");
publishAlarm(false); publishStatus();
}
} else if (danger) {
fireClearSince = 0;
}
}
void driveSiren() {
static int lastFreq = 0;
int freq = (fireAlarm || sirenRemote) ? ((millis() / 300) % 2 ? 1500 : 1000) : 0;
if (freq == lastFreq) return; // only touch the PWM when the note changes
lastFreq = freq;
if (freq) tone(PIN_BUZZER, freq); else noTone(PIN_BUZZER);
}
void handleButton() {
int b = digitalRead(PIN_BTN);
if (b != lastBtn && millis() - lastBtnChange > 50) {
lastBtnChange = millis(); lastBtn = b;
if (b == LOW) {
armed = !armed;
Serial.printf("[btn] armed=%d\n", armed);
publishStatus();
}
}
}
void updateLcd() {
lcd.setCursor(0, 0);
if (fireAlarm) lcd.print("!! FIRE ALARM !!");
else {
char l1[17];
snprintf(l1, sizeof(l1), "%-6s T:%5.1fC", armed ? "ARMED" : "OFF", isnan(tempC) ? 0.0 : tempC);
lcd.print(l1);
}
char l2[17];
snprintf(l2, sizeof(l2), "G:%4d D:%3dcm%c", gasRaw, (int)distCm, mqtt.connected() ? '*' : ' ');
lcd.setCursor(0, 1); lcd.print(l2);
}
// ---------------- Setup / loop ----------------
void setup() {
Serial.begin(115200);
pinMode(PIN_PIR, INPUT);
pinMode(PIN_TRIG, OUTPUT);
pinMode(PIN_ECHO, INPUT);
pinMode(PIN_GAS_D, INPUT);
pinMode(PIN_LED, OUTPUT);
pinMode(PIN_BTN, INPUT_PULLUP);
analogReadResolution(12);
dht.setup(PIN_DHT, DHTesp::DHT22);
lcd.init(); lcd.backlight();
lcd.setCursor(0, 0); lcd.print("IDS node booting");
calibrateBaseline();
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS, 6); // channel 6 skips the scan in Wokwi
configTime(3600, 0, "pool.ntp.org"); // Tunisia UTC+1; timestamps are epoch anyway
mqtt.setServer(MQTT_HOST, MQTT_PORT);
mqtt.setCallback(onMessage);
mqtt.setBufferSize(2304); // event messages are ~1-1.5 KB
lcd.clear();
Serial.println("[boot] ready");
}
void loop() {
uint32_t now = millis();
if (now - lastSample >= SAMPLE_MS) { lastSample = now; sampleFast(); }
if (now - lastDht >= 2000) { lastDht = now; sampleDht(); }
if (now - lastTHist >= 10000) { lastTHist = now; updateTempHistory(); }
fireRule();
driveSiren();
handleButton();
maintainNetwork();
bool changed = (!isnan(tempC) && (isnan(lastSentTemp) || fabs(tempC - lastSentTemp) > 0.5))
|| abs(gasRaw - lastSentGas) > 200;
if (now - lastTelemetry >= TELEMETRY_MS || (changed && now - lastTelemetry > 2000)) publishTelemetry();
if (now - lastLcd >= 500) { lastLcd = now; updateLcd(); }
}