VOUT Analysis for TPS613221
WIFI_POWER_8_5dBm, the ~2500 ms WiFi connect duration, and C2/C4/C5 energy contribution: TPS613221A VOUT vs AA Battery Voltage.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
gpio_hold_en() before sleeping. Details are commented at armWakeAndSleep().Table
| Define | What to put there |
|---|---|
WIFI_SSID / WIFI_PASS | Your 2.4 GHz network credentials |
REPORT_URL | http://:8080/event |
REPORT_TOKEN | Any shared secret — must match SHARED_TOKEN in the server |
SHARED_TOKEN to the same secret, and optionally NTFY_URL to an ntfy topic for phone push notifications.door_window_sensor_wifi.inoCpp
/* ============================================================================ * 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); * --------------------------------------------------------------------------*/
sensor_server.pypip 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")
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}'
events.jsonl — one JSON object per line, easy to analyze later. If NTFY_URL is set, opened/closed events are forwarded as push notifications.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 }
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.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