Community project

Offline Home Security Hub

ESP32
Photo of Offline Home Security Hub
Generated with AI

Sameep Jagtap

Published October 12, 2026

This offline home security hub runs on an ESP32-C6 microcontroller and provides local threat detection without relying on cloud connectivity. It monitors for smoke via an MQ-2 gas sensor, tracks environmental conditions with a DHT11 temperature sensor, and offers four security modes—Home, Away, Night, and Panic—each with visual feedback through status LEDs and audible alerts via a buzzer.

The guide includes a complete wiring diagram showing how to connect the mode selection buttons, status indicator lights, gas sensor input, and buzzer driver circuit using an NPN transistor. You'll also find the full firmware source code, a detailed parts list, and step-by-step assembly instructions for integrating both the main controller and optional WiFi expansion board via UART cross-connection.

Wiring diagram

Wiring diagram for Offline Home Security Hub

Gather all the parts

QtyComponent
1

MQ-2 Gas Sensor

Analog combustible-gas and smoke sensor module. Detects LPG, propane, methane, hydrogen, alcohol, and smoke using a heated tin-dioxide (SnO2) sensing element whose resistance falls as gas concentration rises. The breakout board provides an analog output (AO) proportional to concentration plus a digital output (DO) that trips when concentration crosses an onboard potentiometer threshold. Requires a 5 V supply for the internal heater and a 20-30 s warm-up before readings stabilise; the AO voltage is relative, so the sensor needs a clean-air baseline calibration rather than reporting an absolute ppm value.

1

DHT11

Digital temperature and humidity sensor (lower accuracy than DHT22)

1

5 V active buzzer

A self-oscillating 5 V buzzer that makes the alarm sound when its power is switched on.

1

NPN Transistor (2N2222 / BC547)

General-purpose NPN bipolar junction transistor (BJT) in TO-92 package. Used as a low-side switch to drive loads (e.g. buzzers) whose current exceeds ESP32 GPIO source capability (~12 mA). A ~1 kΩ base resistor is placed between the ESP32 GPIO and the transistor base. The collector connects to the load negative terminal (buzzer –), and the emitter connects to GND. When the GPIO goes HIGH (3.3 V), the transistor saturates and switches the load on. Compatible with both 2N2222/2N2222A and BC547/BC547B variants. Add a flyback diode (e.g. 1N4148) across inductive load terminals.

1

1N4007 diode

A protection diode placed across the buzzer to absorb a switch-off voltage spike.

1

Resistor

1 kΩ

Through-hole resistor (current-limiting in series with an LED)

1

LED

green

Standard 3mm/5mm through-hole LED. A current-limiting series resistor is added automatically.

1

LED

yellow

Standard 3mm/5mm through-hole LED. A current-limiting series resistor is added automatically.

1

LED

red

Standard 3mm/5mm through-hole LED. A current-limiting series resistor is added automatically.

1

Resistor

330 Ω

Through-hole resistor (current-limiting in series with an LED)

1

Resistor

330 Ω

Through-hole resistor (current-limiting in series with an LED)

1

Resistor

330 Ω

Through-hole resistor (current-limiting in series with an LED)

1

Resistor

12 kΩ

Through-hole resistor (current-limiting in series with an LED)

1

Resistor

20 kΩ

Through-hole resistor (current-limiting in series with an LED)

1

Resistor

10 kΩ

Through-hole resistor (current-limiting in series with an LED)

1

Home momentary pushbutton

A pushbutton that selects Home mode when pressed.

1

Away momentary pushbutton

A pushbutton that selects Away mode when pressed.

1

Night momentary pushbutton

A pushbutton that selects Night mode when pressed.

1

Panic momentary pushbutton

A pushbutton that raises the alarm only after it has been held for two seconds.

1

WeAct ESP32-C6-MiNi — Core WiFi

The second ESP32-C6 board, powered by its own USB-C cable, which relays Telegram commands and alerts over the shared UART wires.

Assemble it in 7 steps

1. Power both controller boards separately

Plug each WeAct ESP32-C6-MiNi into its own USB-C cable. Run one jumper from a GND pin on Core Thread to a GND pin on Core WiFi so both boards agree what a signal voltage means; do not join their 5 V pins because each board already receives USB power.

  • Do not connect 5 V to any ESP32-C6 GPIO pin — these pins use 3.3 V only and 5 V can damage the board.

2. Wire the four mode keys

Place four momentary pushbuttons across the breadboard centre gap. For each button, connect one side to Core Thread GND and the opposite side to its GPIO: Home to GPIO10, Away to GPIO11, Night to GPIO2, and Panic to GPIO3. The code keeps each signal high until its button connects it to ground.

  • GPIO14 from the original table is not broken out by the resolved WeAct-compatible pinout, so Night uses GPIO2 instead.

3. Wire the three mode lights

For each LED, connect its long leg through its own 330 Ω resistor to the controller: green to GPIO6, yellow to GPIO7, and red to GPIO19. Connect every short LED leg to GND. The resistors stop too much current damaging an LED or board pin.

  • Make sure each LED has its own 330 Ω resistor; connecting an LED straight to a GPIO can damage it.

4. Build the buzzer switch

Check the BC547’s flat face and datasheet before inserting it because lead order can vary by maker. Connect GPIO18 through the 1 kΩ resistor to its base, emitter to GND, and collector to the buzzer negative pin. Connect buzzer positive to USB 5 V. Put the 1N4007 across the buzzer: striped cathode to 5 V and unstriped anode to the collector/buzzer-negative junction.

  • Do not run the 5 V buzzer directly from GPIO18; the transistor is the safe switch.
  • A reversed diode will not protect the circuit and can create a short when the buzzer runs.

5. Wire the MQ smoke input safely

Connect MQ-2 VCC to 5 V and GND to the shared ground. Connect MQ-2 AO to one end of the 12 kΩ resistor; join its other end to Core Thread GPIO0 and one end of the 20 kΩ resistor; connect the other 20 kΩ end to GND. Leave MQ-2 DO unconnected. This resistor pair reduces the sensor’s 5 V analogue output before it reaches the 3.3 V-only GPIO0.

  • Never connect MQ-2 AO directly to GPIO0 because the MQ board can output close to 5 V and damage the ESP32-C6 input.
  • The MQ heater becomes warm in normal use; keep it away from loose plastic and do not treat this prototype sensor as a certified fire detector.

6. Wire the optional temperature sensor

If using the DHT-11, connect VCC to 3V3, GND to GND, and DATA to GPIO1. Place the 10 kΩ resistor between DATA and 3V3. Do not power the DHT-11 from 5 V because its DATA wire would then rise to 5 V.

  • Make sure the DHT-11’s VCC and GND are not swapped — swapped power can damage the sensor.

7. Cross-connect the two UART wires

Connect Core Thread GPIO22 to Core WiFi GPIO23, then Core Thread GPIO23 to Core WiFi GPIO22. Keep the GND-to-GND jumper from step 1 connected. These are 3.3 V signals, so no voltage converter is needed between the two ESP32-C6 boards.

  • The lines must cross: transmit goes to receive, and receive goes to transmit.

Review all connections

1. Connections between "home_key_1" and "ESP32"

Functionhome_key_1ESP32
digitalGPIO_SIDEGPIO 10
groundGND_SIDEGND

2. Connections between "away_key_1" and "ESP32"

Functionaway_key_1ESP32
digitalGPIO_SIDEGPIO 11
groundGND_SIDEGND

3. Connections between "night_key_1" and "ESP32"

Functionnight_key_1ESP32
groundGND_SIDEGND
digitalGPIO_SIDEGPIO 2

4. Connections between "panic_key_1" and "ESP32"

Functionpanic_key_1ESP32
digitalGPIO_SIDEGPIO 3
groundGND_SIDEGND

5. Connections between "green_led_resistor_1" and "ESP32"

Functiongreen_led_resistor_1ESP32
digitalP1GPIO 6
digitalP2 → LED ANODEEXT

6. Connections between "green_led_1" and "ESP32"

Functiongreen_led_1ESP32
groundGNDGND

7. Connections between "yellow_led_resistor_1" and "ESP32"

Functionyellow_led_resistor_1ESP32
digitalP1GPIO 7
digitalP2 → LED ANODEEXT

8. Connections between "yellow_led_1" and "ESP32"

Functionyellow_led_1ESP32
groundGNDGND

9. Connections between "red_led_resistor_1" and "ESP32"

Functionred_led_resistor_1ESP32
digitalP1GPIO 19
digitalP2 → LED ANODEEXT

10. Connections between "red_led_1" and "ESP32"

Functionred_led_1ESP32
groundGNDGND

11. Connections between "buzzer_base_resistor_1" and "ESP32"

Functionbuzzer_base_resistor_1ESP32
digitalP1GPIO 18
digitalP2 → NPN Transistor (2N2222 / BC547) BASEEXT

12. Connections between "bc547_1" and "ESP32"

Functionbc547_1ESP32
groundEMITTERGND
digitalCOLLECTOR → 5 V active buzzer -EXT

13. Connections between "buzzer_5v_1" and "ESP32"

Functionbuzzer_5v_1ESP32
power+5V

14. Connections between "flyback_diode_1" and "ESP32"

Functionflyback_diode_1ESP32
powerCATHODE5V
digitalANODE → NPN Transistor (2N2222 / BC547) COLLECTOREXT

15. Connections between "mq2_1" and "ESP32"

Functionmq2_1ESP32
powerVCC5V
groundGNDGND
analogAO → Resistor P1EXT

16. Connections between "mq_divider_bottom_1" and "ESP32"

Functionmq_divider_bottom_1ESP32
groundP2GND
digitalP1 → Resistor P2EXT

17. Connections between "dht11_1" and "ESP32"

Functiondht11_1ESP32
powerVCC3V3
groundGNDGND
dataDATAGPIO 1

18. Connections between "dht_pullup_1" and "ESP32"

Functiondht_pullup_1ESP32
powerP13V3
digitalP2 → DHT11 DATAEXT

19. Connections between "mq_divider_top_1" and "ESP32"

Functionmq_divider_top_1ESP32
digitalP2GPIO 0

20. Connections between "core_wifi_1" and "ESP32"

Functioncore_wifi_1ESP32
groundGNDGND
uartRX_GPIO23GPIO 22
uartTX_GPIO22GPIO 23
powerUSB_5V → its own USB-C power cableEXT

Deploy the firmware

#include <Arduino.h>
#include "thread_mesh_stubs.h"

// Nivasa Core Thread: local security controller.
// UART1 uses GPIO22 TX -> Core WiFi GPIO23 RX and GPIO23 RX <- Core WiFi GPIO22 TX.
constexpr int HOME_KEY_PIN = 10;
constexpr int AWAY_KEY_PIN = 11;
constexpr int NIGHT_KEY_PIN = 2; // GPIO14 is not exposed by the resolved WeAct-compatible pinout.
constexpr int PANIC_KEY_PIN = 3;
constexpr int GREEN_LED_PIN = 6;
constexpr int YELLOW_LED_PIN = 7;
constexpr int RED_LED_PIN = 19;
constexpr int BUZZER_PIN = 18;
constexpr int MQ_PIN = 0;
constexpr int DHT_PIN = 1; // Reserved for optional DHT-11 hardware.
constexpr int LINK_TX_PIN = 22;
constexpr int LINK_RX_PIN = 23;

constexpr unsigned long PANIC_HOLD_MS = 2000;
constexpr unsigned long MQ_WARMUP_MS = 120000;
constexpr int SMOKE_THRESHOLD = 2200; // ADC raw value after the divider; tune only after observing a clean-air baseline.
constexpr unsigned long LOCK_IDLE_MS = 30000; // Demo value requested.
constexpr unsigned long LOCK_COUNTDOWN_MS = 120000;
constexpr unsigned long HEARTBEAT_MS = 5000;

enum Mode { HOME, AWAY, NIGHT, ALARM, SMOKE, LOCK_AWAY };
Mode mode = HOME;
String uartLine;
String states[8];
String stateNames[8];
uint8_t stateCount = 0;
String outPeople[8];
uint8_t outCount = 0;
bool mainDoorLocked = false;
bool locking = false;
unsigned long bootMs;
unsigned long panicPressedAt = 0;
unsigned long lockingStartedAt = 0;
unsigned long lastMovementAt = 0;
unsigned long lastHeartbeatAt = 0;
unsigned long lastSmokeSampleAt = 0;
unsigned long lastBuzzerChangeAt = 0;
bool buzzerOn = false;
uint8_t alarmStep = 0;

const char *modeText(Mode value) {
  switch (value) {
    case HOME: return "HOME";
    case AWAY: return "AWAY";
    case NIGHT: return "NIGHT";
    case ALARM: return "ALARM";
    case SMOKE: return "SMOKE";
    case LOCK_AWAY: return "LOCK_AWAY";
  }
  return "HOME";
}

void sendWiFi(const String &line) {
  Serial1.println(line);
  Serial.println("UART> " + line);
}

void setBuzzer(bool on) {
  buzzerOn = on;
  digitalWrite(BUZZER_PIN, on ? HIGH : LOW);
}

void updateLights() {
  digitalWrite(GREEN_LED_PIN, (mode == HOME) ? HIGH : LOW);
  digitalWrite(YELLOW_LED_PIN, (mode == AWAY || mode == NIGHT || mode == LOCK_AWAY) ? HIGH : LOW);
  digitalWrite(RED_LED_PIN, (mode == ALARM || mode == SMOKE) ? HIGH : LOW);
}

void setMode(Mode next, bool report = true) {
  if (mode == SMOKE && next != SMOKE) return; // Smoke remains dominant until power reset in this prototype.
  if (mode == next) return;
  mode = next;
  locking = false;
  updateLights();
  broadcastMode(modeText(mode));
  if (report) sendWiFi("MODE " + String(modeText(mode)));
}

void rememberState(const String &piece, const String &text) {
  for (uint8_t i = 0; i < stateCount; ++i) {
    if (stateNames[i] == piece) { states[i] = text; return; }
  }
  if (stateCount < 8) {
    stateNames[stateCount] = piece;
    states[stateCount] = text;
    ++stateCount;
  }
}

void raiseAlarm(const String &piece, const String &reason) {
  if (mode == SMOKE) return;
  onEvent(piece, reason);
  setMode(ALARM);
  sendWiFi("ALARM " + piece);
}

void enterSmoke() {
  if (mode == SMOKE) return;
  mode = SMOKE;
  locking = false;
  updateLights();
  broadcastMode("SMOKE");
  sendCmd("all", "UNLOCK");
  sendWiFi("SMOKE");
  sendWiFi("MODE SMOKE");
}

void startLockCountdown() {
  if (locking || mode == SMOKE || mode == ALARM) return;
  locking = true;
  lockingStartedAt = millis();
  sendWiFi("LOCKING 120");
}

void finishLockAway() {
  locking = false;
  sendCmd("all", "LOCK");
  setMode(LOCK_AWAY);
}

bool everyoneOut() {
  return outCount > 0; // Prototype has no configured family roster; each /out is treated as the known roster for demonstration.
}

void handleCommand(String line) {
  line.trim();
  if (line.length() == 0) return;
  Serial.println("CMD< " + line);

  if (line.startsWith("event ")) {
    int first = line.indexOf(' ', 6);
    if (first > 0) {
      String piece = line.substring(6, first);
      String name = line.substring(first + 1);
      if (name == "forced" || name == "tamper") raiseAlarm(piece, name);
      else if (name == "movement") lastMovementAt = millis();
    }
    return;
  }
  if (line == "smoke") { enterSmoke(); return; }
  if (line.startsWith("MODE ")) {
    String value = line.substring(5); value.toUpperCase();
    if (value == "HOME") setMode(HOME);
    else if (value == "AWAY") setMode(AWAY);
    else if (value == "NIGHT") setMode(NIGHT);
    return;
  }
  if (line.startsWith("UNLOCK ")) {
    String piece = line.substring(7);
    sendCmd(piece, "UNLOCK");
    return;
  }
  if (line.startsWith("OUT ")) {
    String person = line.substring(4);
    bool known = false;
    for (uint8_t i = 0; i < outCount; ++i) if (outPeople[i] == person) known = true;
    if (!known && outCount < 8) outPeople[outCount++] = person;
    if (everyoneOut() && mainDoorLocked && millis() - lastMovementAt >= LOCK_IDLE_MS) startLockCountdown();
    return;
  }
  if (line.startsWith("HOME ")) {
    String person = line.substring(5);
    for (uint8_t i = 0; i < outCount; ++i) {
      if (outPeople[i] == person) { outPeople[i] = outPeople[--outCount]; break; }
    }
    if (locking) { locking = false; sendWiFi("STATE core LOCK_CANCELLED"); }
    setMode(HOME);
    return;
  }
  if (line.startsWith("STATE ")) {
    int space = line.indexOf(' ', 6);
    if (space > 0) {
      String piece = line.substring(6, space);
      String text = line.substring(space + 1);
      rememberState(piece, text);
      if (piece == "door" && text == "LOCKED") mainDoorLocked = true;
      if (text == "MOVEMENT") lastMovementAt = millis();
      sendWiFi(line);
    }
  }
}

void serviceUart() {
  while (Serial1.available()) {
    char c = static_cast<char>(Serial1.read());
    if (c == '\n') { handleCommand(uartLine); uartLine = ""; }
    else if (c != '\r' && uartLine.length() < 120) uartLine += c;
  }
  while (Serial.available()) {
    char c = static_cast<char>(Serial.read());
    if (c == '\n') { handleCommand(uartLine); uartLine = ""; }
    else if (c != '\r' && uartLine.length() < 120) uartLine += c;
  }
}

void serviceKeys() {
  if (digitalRead(HOME_KEY_PIN) == LOW) { setMode(HOME); delay(25); }
  if (digitalRead(AWAY_KEY_PIN) == LOW) { setMode(AWAY); delay(25); }
  if (digitalRead(NIGHT_KEY_PIN) == LOW) { setMode(NIGHT); delay(25); }
  if (digitalRead(PANIC_KEY_PIN) == LOW) {
    if (panicPressedAt == 0) panicPressedAt = millis();
    if (millis() - panicPressedAt >= PANIC_HOLD_MS) { raiseAlarm("core", "panic"); panicPressedAt = ULONG_MAX / 2; }
  } else {
    panicPressedAt = 0;
  }
}

void serviceSmoke() {
  if (millis() - bootMs < MQ_WARMUP_MS || millis() - lastSmokeSampleAt < 500) return;
  lastSmokeSampleAt = millis();
  int raw = analogRead(MQ_PIN);
  Serial.printf("MQ raw: %d\n", raw);
  if (raw >= SMOKE_THRESHOLD) enterSmoke();
}

void serviceLocking() {
  if (!locking) return;
  unsigned long elapsed = millis() - lockingStartedAt;
  if (elapsed >= LOCK_COUNTDOWN_MS) finishLockAway();
}

void serviceBuzzer() {
  unsigned long now = millis();
  if (mode == SMOKE) { setBuzzer(true); return; } // Long continuous alarm.
  if (mode != ALARM) { setBuzzer(false); return; }
  static const unsigned long pattern[] = {180, 80, 420, 220}; // Active buzzer: alternating pulse lengths, not pitch.
  if (now - lastBuzzerChangeAt >= pattern[alarmStep]) {
    lastBuzzerChangeAt = now;
    alarmStep = (alarmStep + 1) % 4;
    setBuzzer(!buzzerOn);
  }
}

void setup() {
  Serial.begin(115200);
  Serial1.begin(115200, SERIAL_8N1, LINK_RX_PIN, LINK_TX_PIN);
  pinMode(HOME_KEY_PIN, INPUT_PULLUP); pinMode(AWAY_KEY_PIN, INPUT_PULLUP);
  pinMode(NIGHT_KEY_PIN, INPUT_PULLUP); pinMode(PANIC_KEY_PIN, INPUT_PULLUP);
  pinMode(GREEN_LED_PIN, OUTPUT); pinMode(YELLOW_LED_PIN, OUTPUT); pinMode(RED_LED_PIN, OUTPUT);
  pinMode(BUZZER_PIN, OUTPUT); setBuzzer(false);
  analogReadResolution(12);
  bootMs = millis(); lastMovementAt = bootMs;
  updateLights();
  Serial.println("Nivasa Core Thread ready. Test with: event pebble forced");
  sendWiFi("MODE HOME");
}

void loop() {
  serviceUart();
  serviceKeys();
  serviceSmoke();
  serviceLocking();
  serviceBuzzer();
  if (millis() - lastHeartbeatAt >= HEARTBEAT_MS) { lastHeartbeatAt = millis(); sendWiFi("HEARTBEAT"); }
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik