VOUT Analysis for TPS613221

Created the VOUT-vs-AA battery low-battery analysis and graph in the project document: TPS613221A VOUT vs AA Battery Voltage.
Key takeaway: IC1 should hold ~3.3 V for light loads deep into AA discharge, but high-current WiFi bursts become risky below ~0.9–1.0 V because the boost converter can hit current limit and VOUT collapses.
Keep in mind that in code I set wifi power to lowest level WIFI_POWER_8_5dBm. Wifi Connect last about 2500ms in average. also do not forget about C2, C4 and C5 Now update the graph
I’ll update the low-battery graph to include the reduced WiFi TX power, ~2.5 s connect burst, and the stored energy from C2/C4/C5.
Updated the graph/document with WIFI_POWER_8_5dBm, the ~2500 ms WiFi connect duration, and C2/C4/C5 energy contribution: TPS613221A VOUT vs AA Battery Voltage.
Key change: the relevant low-power WiFi curves are now 75–100 mA; C2/C4 help only with short transients (~0.065 ms at 100 mA), so the 2.5 s connect window is dominated by AA cell loaded voltage, not capacitor storage.
Validate low-battery reliability
Check Espressif current
Set firmware thresholds
Review power layout
Estimate AA ESR impact
BT1
ESP1
Not Recommended for New Designs
Manufacturer Part Number
ESP32-C3-MINI-1-N4
+3V3
USER
BOOT
C5
Capacitance
0.01uF
ESP_RST
+3V3
C6
Capacitance
0.01uF
REED
IO8
C3
Capacitance
0.01uF
C4
Capacitance
22uF
+3V3
VBAT
+3V3
LED_R
+3V3
BOOT
+3V3
ESP_TX
BOOT
C7
Capacitance
1uF
LED_G
IO2
LED_R
LED_B
ESP_RST
BOOT
LED_B
IO8
USER
ESP_RST
+3V3
C2
Capacitance
22uF
LED_G
REED
C8
Capacitance
22uF
+3V3
ESP_RX
IO2
+3V3
C1
Capacitance
0.01uF
ESP_TX
ESP_RX
R9
Resistance
10kΩ
U1
R6
Resistance
50 Ω
R1
Resistance
10kΩ
LED1
2
BOOT
7
USER
3
18_USB_DN
R3
Resistance
130 Ω
R7
Resistance
10kΩ
R8
Resistance
10kΩ
19_USB_DP
10
R2
Resistance
1MΩ
R5
Resistance
50 Ω
EN
8
R4
Resistance
50 Ω
.1
Regular non-rechargeable AAA battery
Line 1
Line 1
IC1
.2
J1
Q1
Manufacturer Part Number
DMG2305UX-7
REED1
Manufacturer Part Number
59170-1-S-00-D
L2
Inductance
2.2uH

Refine this doc
Ask about this doc
Firmware and server code
The complete, working software for the sensor: Arduino firmware for the ESP32-C3 board and a zero-dependency Python server that receives its events on the LAN. Both were generated from this project's Firmware Technical Requirements document — a spec derived directly from the schematic — and then debugged on real hardware. The full story of that bring-up (including the ESP32-C3 deep-sleep wake bug this code works around) is in the build article.
How it works
reed changes state
      │
      ▼
ESP32-C3 wakes from deep sleep (~11 µA idle)
      │  debounce ~30 ms, read reed + user button
      ▼
connect WiFi → HTTP POST (plain, LAN-only) ───►  Python server on an always-on box
      │                                            │  logs to events.jsonl
      ▼                                            ▼
back to deep sleep                          optional push to phone via ntfy
Design decisions baked into this code:
  • Plain HTTP on purpose. A TLS handshake is one of the most expensive things a single-AA + boost-converter design can do, and a serious brownout risk during the RF burst. The battery device sends cheap plain HTTP on the local network; the mains-powered server does any TLS/push forwarding.
  • Bounded everything. WiFi connect has a hard timeout, HTTP reports get a fixed retry count, and the device always returns to deep sleep — a dead server must never become a dead battery.
  • The ESP32-C3 wake fix. For a HIGH-level deep-sleep wake, the C3 silently enables an internal ~10 kΩ pull-down that fights the board's external 1 MΩ reed pull-up and pins the pin near 0.4 V — so "window opened" never woke the chip. The firmware disables internal pulls on the reed pin and latches the pad with gpio_hold_en() before sleeping. Details are commented at armWakeAndSleep().
  • State survives sleep. Boot counter, event counter, and last reed state live in RTC memory, so the device suppresses duplicate reports after spurious wakes and the server can detect missed events from counter gaps.
Configuration
In the firmware, set four values at the top of the file:

Table


DefineWhat to put there
WIFI_SSID / WIFI_PASSYour 2.4 GHz network credentials
REPORT_URLhttp://:8080/event
REPORT_TOKENAny shared secret — must match SHARED_TOKEN in the server
In the server, set SHARED_TOKEN to the same secret, and optionally NTFY_URL to an ntfy topic for phone push notifications.
Build environment: Arduino IDE 2.3.x, esp32 board package 2.0.17+ (or 3.x — the LEDC calls are version-guarded), board ESP32C3 Dev Module, serial monitor 115200 baud. No external libraries needed — WiFi and HTTPClient ship with the core.
Arduino firmware — door_window_sensor_wifi.ino

Cpp


/* ============================================================================
 *  WiFi Door & Window Sensor  -  ESP32-C3-MINI-1-N4   (WiFi reporting build)
 *
 *  Flow:  wake -> debounce reed -> connect WiFi -> HTTP POST JSON -> deep sleep
 *  Implements spec section 7.2: store-and-go connect, bounded retries, optional
 *  static IP, lowest usable TX power, sub-second target on-time, timing logs.
 *
 *  Targets: Arduino-ESP32 core 2.0.17+ or 3.0.7+ (LEDC calls version-guarded).
 *  No external libraries required (WiFi + HTTPClient are in the core).
 *
 *  Power note: this build uses PLAIN HTTP (no TLS) on purpose. On a single AA +
 *  boost, a TLS handshake is a major energy/brownout risk. Send to a LAN server
 *  over http://; let that mains-powered server do any TLS/push forwarding.
 * ==========================================================================*/

#include <WiFi.h>
#include <HTTPClient.h>
#include "esp_sleep.h"
#include "driver/gpio.h"   // gpio_hold_*, gpio_pullup/pulldown_dis (C3 wake fix)

/* ---------------------------------------------------------------- build cfg */
#define DEBUG_SERIAL   1
#define FW_VERSION     "wifi-1.0.0"

/* ---- WiFi credentials --------------------------------------------------- *
 * Bring-up: hardcoded here. Production: move to NVS provisioning (SoftAP
 * captive portal). WiFi.persistent(false) avoids re-flashing creds every boot. */
#define WIFI_SSID      "YOUR_SSID"
#define WIFI_PASS      "YOUR_PASSWORD"

/* ---- Report endpoint ---------------------------------------------------- *
 * Point at your free server. Examples:
 *   self-hosted Python receiver (recommended): http://192.168.1.50:8080/event
 *   self-hosted ntfy on LAN (plain HTTP):      http://192.168.1.50:2586/doorsensor
 *   public ntfy.sh (HTTPS, costs battery):     needs WiFiClientSecure, see notes */
#define REPORT_URL     "http://192.168.1.50:8080/event"
#define REPORT_TOKEN   "change-me-shared-secret"   // sent as: Authorization: Bearer ...

/* ---- Optional static IP: skips DHCP, shaves a few hundred ms off on-time -- */
#define USE_STATIC_IP  0
static IPAddress staticIP(192, 168, 1, 123);
static IPAddress gateway (192, 168, 1, 1);
static IPAddress subnet  (255, 255, 255, 0);
static IPAddress dns1    (192, 168, 1, 1);

/* ---- Timing / retry budget (spec 7.2 / 13) ------------------------------ */
#define WIFI_CONNECT_TIMEOUT_MS  8000   // bounded; static IP usually connects <1s
#define HTTP_TIMEOUT_MS          3000
#define MAX_REPORT_ATTEMPTS      2
#define WIFI_TX_POWER            WIFI_POWER_8_5dBm  // tune: lower = less brownout
#define DEBOUNCE_SETTLE_MS       30
#define DEBOUNCE_SAMPLES         8
#define USE_DAILY_FAILSAFE       0
#define FAILSAFE_SECONDS         (24UL * 3600UL)

/* --------------------------------------------------------------- pin map (S4) */
#define PIN_REED   0
#define PIN_USER   1
#define PIN_LED_R  4
#define PIN_LED_G  5
#define PIN_LED_B  6
/* GPIO2 = boot strap, never driven. */

enum EventCode { EVT_BOOT = 1, EVT_OPENED = 2, EVT_CLOSED = 3, EVT_USER = 4 };
static const char* EVENT_NAME[] = { "?", "boot", "opened", "closed", "user" };

/* --------------------------------------------------- state preserved in RTC */
RTC_DATA_ATTR uint32_t bootCount      = 0;
RTC_DATA_ATTR uint32_t eventCount     = 0;
RTC_DATA_ATTR uint32_t failCount      = 0;   // consecutive report failures
RTC_DATA_ATTR bool     lastReedClosed = false;
RTC_DATA_ATTR bool     rtcInitialized = false;

/* ------------------------------------------------------------------ logging */
#if DEBUG_SERIAL
  #define LOG(...)  Serial.printf(__VA_ARGS__)
  #define LOGLN(s)  Serial.println(s)
#else
  #define LOG(...)
  #define LOGLN(s)
#endif

/* ===================== RGB LED (LEDC PWM, common anode, active LOW) ======= */
#define LEDC_FREQ 5000
#define LEDC_RES_BITS 8
#define LEDC_CH_R 0
#define LEDC_CH_G 1
#define LEDC_CH_B 2

static void ledChannelWrite(uint8_t pin, uint8_t ch, uint8_t brightness) {
  uint8_t duty = 255 - brightness;
#if ESP_ARDUINO_VERSION >= ESP_ARDUINO_VERSION_VAL(3, 0, 0)
  (void)ch; ledcWrite(pin, duty);
#else
  (void)pin; ledcWrite(ch, duty);
#endif
}
static void ledSet(uint8_t r, uint8_t g, uint8_t b) {
  ledChannelWrite(PIN_LED_R, LEDC_CH_R, r);
  ledChannelWrite(PIN_LED_G, LEDC_CH_G, g);
  ledChannelWrite(PIN_LED_B, LEDC_CH_B, b);
}
static void ledSetup() {
#if ESP_ARDUINO_VERSION >= ESP_ARDUINO_VERSION_VAL(3, 0, 0)
  ledcAttach(PIN_LED_R, LEDC_FREQ, LEDC_RES_BITS);
  ledcAttach(PIN_LED_G, LEDC_FREQ, LEDC_RES_BITS);
  ledcAttach(PIN_LED_B, LEDC_FREQ, LEDC_RES_BITS);
#else
  ledcSetup(LEDC_CH_R, LEDC_FREQ, LEDC_RES_BITS); ledcAttachPin(PIN_LED_R, LEDC_CH_R);
  ledcSetup(LEDC_CH_G, LEDC_FREQ, LEDC_RES_BITS); ledcAttachPin(PIN_LED_G, LEDC_CH_G);
  ledcSetup(LEDC_CH_B, LEDC_FREQ, LEDC_RES_BITS); ledcAttachPin(PIN_LED_B, LEDC_CH_B);
#endif
  ledSet(0, 0, 0);
}
static void ledFlash(uint8_t r, uint8_t g, uint8_t b, uint16_t ms) {
  ledSet(r, g, b); delay(ms); ledSet(0, 0, 0);
}

/* ============================ inputs ===================================== */
static bool readReedClosed() {           // true = LOW = closed
  uint8_t lo = 0;
  for (uint8_t i = 0; i < DEBOUNCE_SAMPLES; i++) { if (digitalRead(PIN_REED) == LOW) lo++; delay(2); }
  return lo > (DEBOUNCE_SAMPLES / 2);
}
static bool readUserPressed() {
  uint8_t lo = 0;
  for (uint8_t i = 0; i < DEBOUNCE_SAMPLES; i++) { if (digitalRead(PIN_USER) == LOW) lo++; delay(2); }
  return lo > (DEBOUNCE_SAMPLES / 2);
}

/* ============================ WiFi report ================================ */
static bool wifiConnect(uint32_t& connectMs) {
  uint32_t t0 = millis();
  WiFi.persistent(false);
  WiFi.mode(WIFI_STA);
  WiFi.setSleep(true);
  WiFi.setTxPower(WIFI_TX_POWER);
#if USE_STATIC_IP
  WiFi.config(staticIP, gateway, subnet, dns1);   // skip DHCP
#endif
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while (WiFi.status() != WL_CONNECTED) {
    if (millis() - t0 > WIFI_CONNECT_TIMEOUT_MS) { connectMs = millis() - t0; return false; }
    delay(10);
  }
  connectMs = millis() - t0;
  return true;
}

static void buildJson(char* buf, size_t n, uint8_t evt, bool reedClosed,
                      bool userPressed, const char* deviceId, uint32_t connectMs) {
  snprintf(buf, n,
    "{\"device_id\":\"%s\",\"fw\":\"%s\",\"event\":%u,\"event_name\":\"%s\","
    "\"reed_closed\":%s,\"user_pressed\":%s,\"event_count\":%lu,\"boot_count\":%lu,"
    "\"fail_count\":%lu,\"rssi\":%d,\"connect_ms\":%lu,\"reset_reason\":%d}",
    deviceId, FW_VERSION, evt, EVENT_NAME[evt <= 4 ? evt : 0],
    reedClosed ? "true" : "false", userPressed ? "true" : "false",
    (unsigned long)eventCount, (unsigned long)bootCount, (unsigned long)failCount,
    (int)WiFi.RSSI(), (unsigned long)connectMs, (int)esp_reset_reason());
}

static bool postEvent(const char* json, int& code) {
  WiFiClient client;
  HTTPClient http;
  http.setConnectTimeout(HTTP_TIMEOUT_MS);
  http.setTimeout(HTTP_TIMEOUT_MS);
  http.setReuse(false);
  if (!http.begin(client, REPORT_URL)) return false;
  http.addHeader("Content-Type", "application/json");
  http.addHeader("Authorization", String("Bearer ") + REPORT_TOKEN);
  code = http.POST((uint8_t*)json, strlen(json));
  http.end();
  return code > 0;
}

// Returns true if the server accepted the event (2xx).
static bool reportEvent(uint8_t evt, bool reedClosed, bool userPressed) {
  char deviceId[13];
  uint64_t mac = ESP.getEfuseMac();
  snprintf(deviceId, sizeof(deviceId), "%04X%08X",
           (uint16_t)(mac >> 32), (uint32_t)mac);

  uint32_t connectMs = 0;
  uint32_t txStart = millis();
  if (!wifiConnect(connectMs)) {
    LOG("[WiFi] connect FAILED after %lums\n", (unsigned long)connectMs);
    WiFi.disconnect(true); WiFi.mode(WIFI_OFF);
    return false;
  }
  LOG("[WiFi] connected ip=%s rssi=%d connect=%lums\n",
      WiFi.localIP().toString().c_str(), (int)WiFi.RSSI(), (unsigned long)connectMs);

  char json[320];
  buildJson(json, sizeof(json), evt, reedClosed, userPressed, deviceId, connectMs);

  bool ok = false; int code = 0;
  for (uint8_t attempt = 1; attempt <= MAX_REPORT_ATTEMPTS && !ok; attempt++) {
    if (postEvent(json, code) && code >= 200 && code < 300) { ok = true; break; }
    LOG("[HTTP] attempt %u -> code=%d\n", attempt, code);
    delay(150);
  }

  uint32_t onTime = millis() - txStart;
  LOG("[HTTP] %s code=%d totalOn=%lums json=%s\n",
      ok ? "OK" : "FAIL", code, (unsigned long)onTime, json);

  WiFi.disconnect(true);
  WiFi.mode(WIFI_OFF);                       // radio off before sleep
  return ok;
}

/* ============================ sleep / wake =============================== */
static void armWakeAndSleep(bool reedClosedNow) {
  ledSet(0, 0, 0);
  uint64_t mask; esp_deepsleep_gpio_wake_up_mode_t mode;
  if (reedClosedNow) {                        // closed -> wake on OPEN (HIGH)
    mask = (1ULL << PIN_REED);  mode = ESP_GPIO_WAKEUP_GPIO_HIGH;
  } else {                                    // open -> wake on CLOSE (LOW) + USER
    mask = (1ULL << PIN_REED) | (1ULL << PIN_USER); mode = ESP_GPIO_WAKEUP_GPIO_LOW;
  }

  /* ESP32-C3 HIGH-wake fix --------------------------------------------------
   * The reed has an external 1 MOhm pull-up. By default the deep-sleep driver
   * auto-adds an internal pull-DOWN (~10k) for a HIGH-level wake; that forms a
   * divider with the 1 MOhm and pins GPIO0 near 0.4 V, so the OPEN (HIGH) wake
   * never fires (close/LOW works fine). Disable internal pulls on the reed and
   * HOLD the pad so the auto-resistor can't override it -> the external 1 MOhm
   * alone defines the level. The user button (GPIO1, no external pull) is left
   * to the default auto pull-up, which is correct for its LOW wake. */
  gpio_hold_dis((gpio_num_t)PIN_REED);
  gpio_pullup_dis((gpio_num_t)PIN_REED);
  gpio_pulldown_dis((gpio_num_t)PIN_REED);

  esp_deep_sleep_enable_gpio_wakeup(mask, mode);

  gpio_hold_en((gpio_num_t)PIN_REED);         // latch: no internal pull during sleep
  gpio_deep_sleep_hold_en();
#if USE_DAILY_FAILSAFE
  esp_sleep_enable_timer_wakeup(FAILSAFE_SECONDS * 1000000ULL);
#endif
  LOG("[SLEEP] reedClosed=%u level=%s fails=%lu\n",
      reedClosedNow, reedClosedNow ? "HIGH" : "LOW", (unsigned long)failCount);
#if DEBUG_SERIAL
  Serial.flush();
#endif
  esp_deep_sleep_start();
}

/* ============================ entry points =============================== */
void setup() {
#if DEBUG_SERIAL
  Serial.begin(115200); delay(50);
#endif
  bootCount++;

  // Release any pad hold latched before the previous deep sleep, so the reed
  // pin can be reconfigured/read normally this cycle (see armWakeAndSleep).
  gpio_deep_sleep_hold_dis();
  gpio_hold_dis((gpio_num_t)PIN_REED);

  pinMode(PIN_REED, INPUT);                   // ext 1M pull-up; no internal pull
  pinMode(PIN_USER, INPUT_PULLUP);
  ledSetup();

  esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause();
  LOG("\n[BOOT] fw=%s boot#=%lu wakeCause=%d reset=%d\n",
      FW_VERSION, (unsigned long)bootCount, (int)cause, (int)esp_reset_reason());

  delay(DEBOUNCE_SETTLE_MS);
  bool reedClosed  = readReedClosed();
  bool userPressed = readUserPressed();
  LOG("[IN] reedClosed=%u userPressed=%u lastReed=%u init=%u\n",
      reedClosed, userPressed, lastReedClosed, rtcInitialized);

  uint8_t evt = 0;
  bool doReport = false;

  if (!rtcInitialized || cause == ESP_SLEEP_WAKEUP_UNDEFINED) {
    evt = EVT_BOOT; doReport = true;          // cold boot / first run
    ledFlash(40, 40, 60, 80);
  } else if (userPressed && reedClosed == lastReedClosed) {
    evt = EVT_USER; doReport = true;          // user button (only armable when open)
    ledFlash(0, 0, 160, 60);
  } else if (reedClosed != lastReedClosed) {
    evt = reedClosed ? EVT_CLOSED : EVT_OPENED;
    doReport = true;
    if (reedClosed) ledFlash(160, 0, 0, 60); else ledFlash(0, 160, 0, 60);
  } else {
    LOGLN("[EVT] no change -> suppress report");   // spurious wake / bounce
  }

  if (doReport) {
    eventCount++;
    bool ok = reportEvent(evt, reedClosed, userPressed);
    failCount = ok ? 0 : (failCount + 1);
    // Optional: error blink only during manual/setup, never in normal sleep.
    if (!ok && cause == ESP_SLEEP_WAKEUP_UNDEFINED) ledFlash(160, 80, 0, 120);
  }

  lastReedClosed = reedClosed;
  rtcInitialized = true;
  armWakeAndSleep(reedClosed);
}

void loop() { /* unreached: all work in setup(), then deep sleep */ }

/* ---------------------------------------------------------------------------
 *  To target public ntfy.sh directly (HTTPS, higher battery cost) instead of a
 *  LAN server, swap postEvent() to use WiFiClientSecure:
 *      #include <WiFiClientSecure.h>
 *      WiFiClientSecure client; client.setInsecure();   // skip cert check
 *      http.begin(client, "https://ntfy.sh/<your-random-topic>");
 *  and send a plain-text body (the notification message) instead of JSON, e.g.
 *      http.addHeader("Title", "Door/Window sensor");
 *      http.POST((uint8_t*)"OPENED", 6);
 * --------------------------------------------------------------------------*/
Python server — sensor_server.py
Runs on any always-on machine (Raspberry Pi, NAS, old PC) with Python 3. Standard library only — no pip install.

Python


#!/usr/bin/env python3
"""
Free door/window sensor receiver.

Receives JSON events from the ESP32-C3 sensor over HTTP POST, logs them to
console + a file, and (optionally) forwards a push notification to ntfy so you
get an alert on your phone.

Zero dependencies: Python 3 standard library only. Run on any always-on box
(Raspberry Pi, NAS, old laptop). The battery sensor talks to this over plain
HTTP on the LAN; this server does any TLS/push work instead.

Run:
    python3 sensor_server.py
Test without hardware:
    curl -X POST http://localhost:8080/event \
         -H "Authorization: Bearer change-me-shared-secret" \
         -H "Content-Type: application/json" \
         -d '{"device_id":"TEST","event":2,"reed_closed":false,"event_count":1}'
"""

import json
import time
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

# ----------------------------- configuration --------------------------------
HOST = "0.0.0.0"          # listen on all interfaces
PORT = 8080
SHARED_TOKEN = "change-me-shared-secret"   # MUST match firmware REPORT_TOKEN ("" disables auth)
LOG_FILE = "events.jsonl"

# Optional phone push via ntfy. Leave empty to disable.
#   public:      "https://ntfy.sh/your-long-random-topic-8f3k2x"
#   self-hosted: "http://192.168.1.50:2586/doorsensor"
# Public ntfy.sh topics are readable by anyone who knows the name -> use a long
# random topic, or self-host with auth for anything security-relevant.
NTFY_URL = ""

EVENT_NAMES = {1: "boot", 2: "opened", 3: "closed", 4: "user"}
EVENT_TAGS  = {1: "gear", 2: "door", 3: "lock", 4: "bell"}


def forward_ntfy(evt: dict) -> None:
    if not NTFY_URL:
        return
    code = evt.get("event")
    name = EVENT_NAMES.get(code, "event")
    dev = evt.get("device_id", "?")
    msg = f"{name.upper()}  (device {dev})"
    try:
        req = urllib.request.Request(
            NTFY_URL,
            data=msg.encode("utf-8"),
            headers={
                "Title": "Door/Window sensor",
                "Tags": EVENT_TAGS.get(code, "bell"),
                "Priority": "high" if name in ("opened", "boot") else "default",
            },
            method="POST",
        )
        urllib.request.urlopen(req, timeout=5)
    except Exception as e:
        print("   ! ntfy forward failed:", e)


class Handler(BaseHTTPRequestHandler):
    def _reply(self, code: int, body: bytes = b"ok") -> None:
        self.send_response(code)
        self.send_header("Content-Type", "text/plain")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self):
        # simple health check
        if self.path.rstrip("/") in ("", "/health"):
            return self._reply(200, b"sensor server up")
        return self._reply(404, b"not found")

    def do_POST(self):
        if self.path.rstrip("/") != "/event":
            return self._reply(404, b"not found")

        if SHARED_TOKEN and self.headers.get("Authorization", "") != f"Bearer {SHARED_TOKEN}":
            return self._reply(401, b"unauthorized")

        length = int(self.headers.get("Content-Length", 0) or 0)
        raw = self.rfile.read(length) if length else b""
        try:
            evt = json.loads(raw.decode("utf-8"))
        except Exception:
            return self._reply(400, b"bad json")

        evt["_received"] = time.strftime("%Y-%m-%d %H:%M:%S")
        with open(LOG_FILE, "a", encoding="utf-8") as f:
            f.write(json.dumps(evt) + "\n")

        name = EVENT_NAMES.get(evt.get("event"), "?")
        print(
            f"[{evt['_received']}] {name:7s} "
            f"dev={evt.get('device_id', '?'):>12} "
            f"reed_closed={evt.get('reed_closed')} "
            f"cnt={evt.get('event_count')} "
            f"rssi={evt.get('rssi')} "
            f"connect_ms={evt.get('connect_ms')} "
            f"fails={evt.get('fail_count')}"
        )

        # Reply to the device FIRST (let it sleep), then forward push.
        self._reply(200, b"ok")
        forward_ntfy(evt)

    def log_message(self, *args):
        pass  # silence default access logging


if __name__ == "__main__":
    print(f"Door/Window sensor server  ->  http://{HOST}:{PORT}/event")
    print(f"Logging events to: {LOG_FILE}")
    print(f"Auth: {'Bearer token required' if SHARED_TOKEN else 'DISABLED'}")
    print(f"ntfy push: {NTFY_URL or 'disabled'}")
    try:
        ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
    except KeyboardInterrupt:
        print("\nbye")
Start it with python3 sensor_server.py, then test without hardware:
curl -X POST http://localhost:8080/event \
     -H "Authorization: Bearer change-me-shared-secret" \
     -H "Content-Type: application/json" \
     -d '{"device_id":"TEST","event":2,"reed_closed":false,"event_count":1}'
Every event prints to the console and is appended to events.jsonl — one JSON object per line, easy to analyze later. If NTFY_URL is set, opened/closed events are forwarded as push notifications.
What the device sends
Each report is a small JSON object:

JSON


{
  "device_id": "A1B2C3D4E5F6",
  "fw": "wifi-1.0.0",
  "event": 2,
  "event_name": "opened",
  "reed_closed": false,
  "user_pressed": false,
  "event_count": 42,
  "boot_count": 57,
  "fail_count": 0,
  "rssi": -61,
  "connect_ms": 830,
  "reset_reason": 8
}
Event codes: 1 boot, 2 opened, 3 closed, 4 user button. The rssi and connect_ms fields are there deliberately — watch them in the log to tune WiFi TX power and decide whether enabling USE_STATIC_IP (which skips DHCP) is worth it for your network. Every ~100 ms shaved off connect_ms is battery life.
BLE variant
The project also has a BLE event-advertising firmware variant (NimBLE, non-connectable advertisements with a compact manufacturer-data payload) that trades the WiFi server for a BLE hub and extends battery life by roughly 8–11× — see the WiFi vs BLE runtime comparison. The payload format is documented in the Firmware Technical Requirements, section 7.1. If you build the BLE variant, apply the same gpio_hold wake fix in its sleep routine — the C3 quirk is identical in both builds.
  • How it works

  • Configuration

  • Arduino firmware — `door_window_sensor_wifi.ino`

  • Python server — `sensor_server.py`

  • What the device sends

  • BLE variant

  • Related project files

WiFi Door and Window Sensor

WiFi Door and Window Sensor thumbnail
A compact battery-powered door/window sensor built around the ESP32-C3-MINI-1-N4 module. The design uses a single non-rechargeable AA cell with a TPS613221A 3.3 V boost regulator, reed switch magnetic contact sensing, low-power wake/report/sleep firmware strategy, RGB status LED for setup feedback, BOOT/EN controls, programming header, and input polarity protection. It is intended for smart-home security and automation applications, supporting WiFi or Bluetooth LE hub reporting with emphasis on low idle current, reliable RF burst power delivery, and field-replaceable battery operation. #ESP32-C3 #BLE #WiFi #DoorSensor #WindowSensor #ReedSwitch #LowPower #BatteryPowered #SmartHome #IoT

Pricing & Availability

Distributor

Qty 1

Arrow

$1.62–$2.68

Digi-Key

$2.20

HQonline

$0.47

LCSC

$5.58–$5.68

Mouser

$3.94

TME

$2.92

Verical

$1.50–$4.74

Controls