penguin/homekey-relay

homekey-relay

commit 84e552cf8bd4f9af774c78504dcbfe845c8885a3

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-13T09:55:12Z
subjectLD2410S: Wake up esp32!
commit 84e552cf8bd4f9af774c78504dcbfe845c8885a3
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-10-13T09:55:12Z

    LD2410S: Wake up esp32!
---
 README.md                       | 88 ++++++++++++++++++++++++++++++++++++++++-
 esp32/main/CMakeLists.txt       |  2 +-
 esp32/main/Kconfig.projbuild    | 37 +++++++++++++++++
 esp32/main/main.c               | 15 +++++++
 esp32/main/pn532.c              | 49 +++++++++++++++++++++++
 esp32/main/presence.c           | 74 ++++++++++++++++++++++++++++++++++
 esp32/main/presence.h           | 10 +++++
 esp32/main/reader.h             |  3 +-
 tests/include/driver/gpio.h     | 18 +++++++++
 tests/include/esp_idf_version.h |  4 ++
 tests/include/esp_sleep.h       |  7 ++++
 tests/include/sdkconfig.h       |  9 +++++
 tests/pn532_test.c              | 60 ++++++++++++++++++++++++++++
 tests/presence_test.c           | 66 +++++++++++++++++++++++++++++++
 tests/run.sh                    |  6 ++-
 15 files changed, 444 insertions(+), 4 deletions(-)

diff --git a/README.md b/README.md
index 05c1402..a145861 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,93 @@ UID advertisements. See [the current relay protocol](esp32/PROTOCOL.md).
 
 
 ## Workflow
-Presence -> HLK-LD2410S -> ESP32-C3 -> PN532
+
+LD2410S OT2 HIGH -> ESP32-C3 wakes/boots -> PN532 UART wake -> BLE reconnect
+and Home group configuration -> ECP + NFC polling.
+
+OT2 LOW -> absence confirmation -> finish any active NFC session -> PN532
+RF off + PowerDown -> stop BLE -> ESP32-C3 deep sleep until OT2 HIGH.
+
+### Presence wiring
+
+| Module pin | ESP32-C3 / supply |
+| --- | --- |
+| LD2410S 3V3 | Always-on regulated 3.3 V |
+| LD2410S GND | Common GND |
+| LD2410S OT2 | **GPIO5** (HIGH = present) |
+| LD2410S OT1/TX and RX | Disconnected in relay firmware |
+| PN532 RX | **GPIO3** |
+| PN532 TX | **GPIO4** |
+| PN532 supply / GND | Existing regulated supply / common GND |
+
+Remove the calibration firmware's radar UART wiring from GPIO3/4 before
+reconnecting PN532. The relay uses OT2 only and does not change the radar's
+saved calibration/settings. Keep LD2410S powered during ESP sleep. PN532 also
+stays supplied: this is its software PowerDown mode, not a switched supply.
+Its UART TX drive from the ESP is held idle HIGH across sleep to avoid false
+wakes. Never connect battery voltage directly to 3.3 V power or GPIO pins.
+
+### Sleep behavior and settings
+
+`idf.py menuconfig` -> **Home Key presence / sleep**:
+
+- Presence sleep is enabled by default on ESP32-C3. Disable it for bench
+  operation without LD2410S; otherwise an unconnected input reads absent.
+- OT2 defaults to GPIO5. Only GPIO0-5 support this C3 deep-sleep wake path;
+  GPIO3/4 conflict with PN532 and are rejected at compile time. Avoid GPIO2
+  because it is a boot strapping pin.
+- Default additional absence confirmation: **1 second** after observing LOW.
+  The radar's own configured absence delay happens first.
+- Default cold/reset boot grace: **10 seconds** awake for sensor startup and
+  USB access. It does not delay NFC startup. GPIO wakes skip this grace.
+
+Deep sleep **disconnects BLE** and wake starts a new boot. The configured reader
+address remains the same, but `homekey-go` must reconnect and resend its Home
+group before polling resumes. Approach-to-ready time therefore includes boot
+and BLE reconnect, not just PN532 wake. No ECP/credential data is cached across
+sleep, and there are no periodic timer wakes.
+
+An active NFC callback finishes (or hits its existing session timeout) before
+the owner task considers sleep. Absence may therefore take longer during an
+authentication. PN532 PowerDown must return a complete success response; a
+failure keeps ESP awake, wakes/reinitializes PN532, and retries. A returning
+HIGH during shutdown triggers an immediate GPIO wake after sleep entry.
+
+The implementation selects the renamed wake API on ESP-IDF 6.x and the older
+API on 5.x. It needs no automatic light-sleep or Bluetooth sleep configuration.
+The standalone pin/PN532 test builds do not use presence control.
+
+### Build and verify on hardware
+
+```sh
+cd esp32
+idf.py menuconfig
+idf.py build
+idf.py -p /dev/cu.usbmodem101 flash monitor
+```
+
+1. Keep someone in range. Expect the OT2 GPIO log, PN532 firmware log, BLE
+   connection, Home Key ECP configuration, and normal phone authentication.
+2. Leave the area. After OT2 goes LOW and the confirmation interval passes,
+   expect `Absent: PN532 asleep, BLE stopped; ESP32 deep sleep`.
+3. Approach again. Expect a new boot and BLE/ECP setup, then test Home Key.
+4. Repeat several cycles; also test presence returning during shutdown and
+   leaving during authentication. Compare supply current awake/asleep.
+
+Native USB can disappear in deep sleep. Approach the sensor to wake it, or
+hold BOOT and tap RESET to enter the ROM downloader if flashing cannot connect.
+Development-board LEDs/regulators and PN532 board circuitry can dominate sleep
+current; chip sleep does not guarantee a particular battery life.
+
+Host tests cover absence timing, cold/GPIO wake policy, disabled presence,
+PN532 wake delay, confirmed/rejected/truncated/missing PowerDown responses,
+the polling-to-sleep transition, and existing APDU/ECP behavior. This change
+has not been compiled with a full ESP-IDF toolchain or tested on physical
+hardware in the development environment.
+
+Protocol references: [LD2410S OT2 definition](https://h.hlktech.com/download/HLK-LD2410S-24G/1/HLK-LD2410S-%E4%B8%B2%E5%8F%A3%E9%80%9A%E4%BF%A1%E5%8D%8F%E8%AE%AE-V1.00%20.pdf),
+[PN532 user manual, section 7.2.11](https://www.nxp.com/docs/en/user-guide/141520.pdf),
+[ESP32-C3 sleep modes](https://docs.espressif.com/projects/esp-idf/en/stable/esp32c3/api-reference/system/sleep_modes.html).
 
 
 ## Legacy Home Assistant UI setup
diff --git a/esp32/main/CMakeLists.txt b/esp32/main/CMakeLists.txt
index e869fb3..3343ad9 100644
--- a/esp32/main/CMakeLists.txt
+++ b/esp32/main/CMakeLists.txt
@@ -16,7 +16,7 @@ elseif(PNC532_TEST)
 	)
 else()
 	idf_component_register(
-		SRCS "main.c" "pn532.c" "relay.c"
+		SRCS "main.c" "pn532.c" "relay.c" "presence.c"
 		INCLUDE_DIRS "."
 		PRIV_REQUIRES bt nvs_flash driver esp_timer
 					  esp_hw_support esp_driver_uart
diff --git a/esp32/main/Kconfig.projbuild b/esp32/main/Kconfig.projbuild
new file mode 100644
index 0000000..e9465be
--- /dev/null
+++ b/esp32/main/Kconfig.projbuild
@@ -0,0 +1,37 @@
+menu "Home Key presence / sleep"
+    config HOMEKEY_PRESENCE_ENABLED
+        bool "Use LD2410S OT2 to control deep sleep"
+        depends on IDF_TARGET_ESP32C3
+        default y
+        help
+            Keep LD2410S powered. OT2 HIGH means present, LOW means absent.
+            Deep sleep disconnects BLE; Go must reconnect on every wake.
+            Disable for bench testing without the sensor.
+
+    config HOMEKEY_PRESENCE_GPIO
+        int "LD2410S OT2 GPIO"
+        depends on HOMEKEY_PRESENCE_ENABLED
+        range 0 5
+        default 5
+        help
+            C3 deep-sleep wake requires GPIO0-5. GPIO3/4 are reserved for
+            PN532 UART and rejected at compile time. Avoid strapping GPIO2.
+
+    config HOMEKEY_PRESENCE_ABSENCE_MS
+        int "Additional absence confirmation (ms)"
+        depends on HOMEKEY_PRESENCE_ENABLED
+        range 100 10000
+        default 1000
+        help
+            Added after the sensor's own absence delay. An active NFC session
+            finishes before sleep is considered.
+
+    config HOMEKEY_PRESENCE_BOOT_GRACE_MS
+        int "Minimum awake time after reset / power-on (ms)"
+        depends on HOMEKEY_PRESENCE_ENABLED
+        range 0 60000
+        default 10000
+        help
+            Gives the sensor time to start and USB time to connect. Skipped
+            on GPIO wake from deep sleep. Does not delay NFC startup.
+endmenu
diff --git a/esp32/main/main.c b/esp32/main/main.c
index 9e896a6..c91e419 100644
--- a/esp32/main/main.c
+++ b/esp32/main/main.c
@@ -1,4 +1,5 @@
 #include <assert.h>
+#include <stdatomic.h>
 #include <string.h>
 #include "esp_err.h"
 #include "esp_log.h"
@@ -13,6 +14,7 @@
 #include "services/gatt/ble_svc_gatt.h"
 #include "reader.h"
 #include "relay.h"
+#include "presence.h"
 #include "freertos/FreeRTOS.h"
 #include "freertos/task.h"
 
@@ -68,6 +70,7 @@ static int apdu_access(uint16_t conn, uint16_t attr, struct ble_gatt_access_ctxt
 }
 static uint8_t own_addr_type;
 static uint16_t connection = BLE_HS_CONN_HANDLE_NONE;
+static atomic_bool shutting_down;
 /* Accessed only by the NimBLE host task. */
 static uint8_t reply[8];
 static bool have_reply;
@@ -157,6 +160,8 @@ static int gap_event(struct ble_gap_event *event, void *arg)
 }
 static void advertise(void)
 {
+	if (atomic_load(&shutting_down))
+		return;
 	struct ble_hs_adv_fields fields = {0};
 	fields.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP;
 	fields.uuids128 = (ble_uuid128_t *)&service_uuid;
@@ -223,11 +228,21 @@ static void nfc_task(void *arg)
 {
 	(void)arg;
 	start_polling(report_card);
+	/* PN532 owner has finished the last session and confirmed PowerDown. */
+	atomic_store(&shutting_down, true);
+	int rc;
+	while ((rc = nimble_port_stop()) != 0) {
+		ESP_LOGE(TAG, "NimBLE stop failed: %d; retrying", rc);
+		vTaskDelay(pdMS_TO_TICKS(1000));
+	}
+	ESP_ERROR_CHECK(nimble_port_deinit());
+	presence_enter_sleep();
 	vTaskDelete(NULL);
 }
 
 void app_main(void)
 {
+	presence_init();
 	relay_init();
 	esp_err_t err = nvs_flash_init();
 	if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
diff --git a/esp32/main/pn532.c b/esp32/main/pn532.c
index 11f063d..fa4a239 100644
--- a/esp32/main/pn532.c
+++ b/esp32/main/pn532.c
@@ -1,6 +1,8 @@
 #include <string.h>
 
 #include "driver/uart.h"
+#include "driver/gpio.h"
+#include "sdkconfig.h"
 #include "esp_err.h"
 #include "esp_log.h"
 #include "esp_timer.h"
@@ -8,6 +10,7 @@
 #include "freertos/task.h"
 #include "reader.h"
 #include "relay.h"
+#include "presence.h"
 
 #define PN532_UART UART_NUM_1
 #define PN532_TX_GPIO 3
@@ -16,6 +19,12 @@
 #define COMMAND_TIMEOUT_MS 1000
 #define POLL_INTERVAL_MS 300
 
+#ifdef CONFIG_HOMEKEY_PRESENCE_ENABLED
+_Static_assert(CONFIG_HOMEKEY_PRESENCE_GPIO != PN532_TX_GPIO &&
+               CONFIG_HOMEKEY_PRESENCE_GPIO != PN532_RX_GPIO,
+               "LD2410S OT2 conflicts with PN532 UART");
+#endif
+
 static const char *TAG = "pn532";
 static const uint8_t ack[] = {0x00, 0x00, 0xff, 0x00, 0xff, 0x00};
 
@@ -33,6 +42,9 @@ static void pn532_uart_init(void)
 	ESP_ERROR_CHECK(uart_param_config(PN532_UART, &config));
 	ESP_ERROR_CHECK(uart_set_pin(PN532_UART, PN532_TX_GPIO, PN532_RX_GPIO,
 								 UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE));
+	/* Restore UART routing before releasing the idle-high sleep hold. */
+	ESP_ERROR_CHECK(gpio_hold_dis(PN532_TX_GPIO));
+	gpio_deep_sleep_hold_dis();
 	ESP_ERROR_CHECK(uart_flush_input(PN532_UART));
 	ESP_LOGI(TAG, "UART ready: TX=GPIO%d RX=GPIO%d baud=%d",
 			 PN532_TX_GPIO, PN532_RX_GPIO, PN532_BAUD);
@@ -292,6 +304,12 @@ static bool initialize_reader(void)
 
 	/* Discard stale input before starting this attempt. */
 	ESP_ERROR_CHECK(uart_flush_input(PN532_UART));
+	/* PowerDown wake requires oscillator startup time after serial activity.
+	 * Keep the proven startup packet too, but do not rely on its short prefix. */
+	static const uint8_t wake[] = {0x55, 0x55};
+	if (!write_bytes(wake, sizeof(wake)))
+		return false;
+	vTaskDelay(pdMS_TO_TICKS(10));
 
 	int n = command(firmware, sizeof(firmware), response);
 	if (n != 6 || response[2] != 0x32) {
@@ -317,6 +335,25 @@ static bool initialize_reader(void)
 	return true;
 }
 
+static bool power_down_reader(void)
+{
+	static const uint8_t power_down[] = {0x16, 0x10}; /* HSU wake only */
+	uint8_t response[128];
+	release_rf();
+	int n = command(power_down, sizeof(power_down), response);
+	if (n != 3 || response[2] != 0) {
+		ESP_LOGE(TAG, "PN532 PowerDown not confirmed; staying awake to retry");
+		return false;
+	}
+	/* PN532 needs ~1ms after its response to enter PowerDown. */
+	vTaskDelay(pdMS_TO_TICKS(10));
+	ESP_ERROR_CHECK(uart_wait_tx_done(PN532_UART, pdMS_TO_TICKS(100)));
+	/* Preserve idle HIGH so the floating TX pin cannot wake the PN532. */
+	ESP_ERROR_CHECK(gpio_hold_en(PN532_TX_GPIO));
+	gpio_deep_sleep_hold_en();
+	return true;
+}
+
 /* ECP v2 Home Key, with software CRC-A. These bytes identify a Home;
  * they are public routing metadata, never authentication credentials. */
 static void build_ecp(uint8_t frame[18], const uint8_t group[8])
@@ -398,6 +435,18 @@ void start_polling(pn532_card_callback_t callback)
 
 	nfc_presence_t presence = {0};
 	for (;;) {
+		/* Only this task touches PN532. The synchronous card callback has
+		 * returned, so an in-flight authentication is never cut off here. */
+		if (presence_sleep_requested()) {
+			if (power_down_reader())
+				return;
+			/* A lost response leaves power state uncertain. Wake and restore
+			 * the reader before retrying; never blindly poll a sleeping chip. */
+			vTaskDelay(pdMS_TO_TICKS(1000));
+			while (!initialize_reader())
+				vTaskDelay(pdMS_TO_TICKS(2000));
+			continue;
+		}
 		nfc_card_t card = {0};
 		uint8_t group[8];
 		bool ecp = relay_ecp_group(group);
diff --git a/esp32/main/presence.c b/esp32/main/presence.c
new file mode 100644
index 0000000..a3f1ba6
--- /dev/null
+++ b/esp32/main/presence.c
@@ -0,0 +1,74 @@
+#include "presence.h"
+#include "sdkconfig.h"
+#include "driver/gpio.h"
+#include "esp_err.h"
+#include "esp_idf_version.h"
+#include "esp_log.h"
+#include "esp_sleep.h"
+#include "esp_timer.h"
+
+#ifdef CONFIG_HOMEKEY_PRESENCE_ENABLED
+static int64_t boot_deadline;
+static int64_t absent_since = -1;
+#endif
+
+void presence_init(void)
+{
+#ifdef CONFIG_HOMEKEY_PRESENCE_ENABLED
+    const gpio_config_t input = {
+        .pin_bit_mask = 1ULL << CONFIG_HOMEKEY_PRESENCE_GPIO,
+        .mode = GPIO_MODE_INPUT,
+        .pull_down_en = GPIO_PULLDOWN_ENABLE,
+        .pull_up_en = GPIO_PULLUP_DISABLE,
+        .intr_type = GPIO_INTR_DISABLE,
+    };
+    ESP_ERROR_CHECK(gpio_config(&input));
+    /* Arm before any shutdown work. Level wake also covers presence returning
+     * between the final check and entry into deep sleep. */
+#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
+    ESP_ERROR_CHECK(esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(
+        input.pin_bit_mask, ESP_GPIO_WAKEUP_GPIO_HIGH));
+#else
+    ESP_ERROR_CHECK(esp_deep_sleep_enable_gpio_wakeup(
+        input.pin_bit_mask, ESP_GPIO_WAKEUP_GPIO_HIGH));
+#endif
+    boot_deadline = esp_timer_get_time();
+#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
+	bool gpio_wakeup = (esp_sleep_get_wakeup_causes() &
+			(1U << ESP_SLEEP_WAKEUP_GPIO)) != 0;
+#else
+	bool gpio_wakeup = esp_sleep_get_wakeup_cause() == ESP_SLEEP_WAKEUP_GPIO;
+#endif
+	if (!gpio_wakeup)
+        boot_deadline += (int64_t)CONFIG_HOMEKEY_PRESENCE_BOOT_GRACE_MS * 1000;
+    ESP_LOGI("presence", "LD2410S OT2=GPIO%d (HIGH=present); deep sleep enabled",
+             CONFIG_HOMEKEY_PRESENCE_GPIO);
+#endif
+}
+
+bool presence_sleep_requested(void)
+{
+#ifdef CONFIG_HOMEKEY_PRESENCE_ENABLED
+    int64_t now = esp_timer_get_time();
+	int level = gpio_get_level(CONFIG_HOMEKEY_PRESENCE_GPIO);
+	// ESP_LOGI("presence", "GPIO%d reads %d", CONFIG_HOMEKEY_PRESENCE_GPIO, level);
+	if (level) {
+        absent_since = -1;
+        return false;
+    }
+    if (absent_since < 0)
+        absent_since = now;
+    return now >= boot_deadline &&
+           now - absent_since >= (int64_t)CONFIG_HOMEKEY_PRESENCE_ABSENCE_MS * 1000;
+#else
+    return false;
+#endif
+}
+
+void presence_enter_sleep(void)
+{
+#ifdef CONFIG_HOMEKEY_PRESENCE_ENABLED
+    ESP_LOGI("presence", "Absent: PN532 asleep, BLE stopped; ESP32 deep sleep");
+    esp_deep_sleep_start();
+#endif
+}
diff --git a/esp32/main/presence.h b/esp32/main/presence.h
new file mode 100644
index 0000000..859f892
--- /dev/null
+++ b/esp32/main/presence.h
@@ -0,0 +1,10 @@
+#ifndef HOMEKEY_PRESENCE_H
+#define HOMEKEY_PRESENCE_H
+#include <stdbool.h>
+
+void presence_init(void);
+/* NFC owner only: checks absence between complete NFC sessions. */
+bool presence_sleep_requested(void);
+/* Called after PN532 PowerDown succeeded and Bluetooth has stopped. */
+void presence_enter_sleep(void);
+#endif
diff --git a/esp32/main/reader.h b/esp32/main/reader.h
index f82c253..baa0b42 100644
--- a/esp32/main/reader.h
+++ b/esp32/main/reader.h
@@ -17,7 +17,8 @@ typedef struct {
 
 typedef bool (*pn532_card_callback_t)(const nfc_card_t *card);
 
-/* Blocks in the caller's task. Callback returns false if its queue is full. */
+/* Blocks in the caller's task. Returns only after absence and successful
+ * PN532 PowerDown; caller must stop BLE and enter deep sleep. */
 void start_polling(pn532_card_callback_t callback);
 /* Only called by the NFC owner task while its target is active. */
 int pn532_exchange(uint8_t target, const uint8_t *apdu, size_t length,
diff --git a/tests/include/driver/gpio.h b/tests/include/driver/gpio.h
new file mode 100644
index 0000000..0991e04
--- /dev/null
+++ b/tests/include/driver/gpio.h
@@ -0,0 +1,18 @@
+#ifndef MOCK_GPIO_H
+#define MOCK_GPIO_H
+#include "mock.h"
+#define GPIO_MODE_INPUT 1
+#define GPIO_PULLDOWN_ENABLE 1
+#define GPIO_PULLUP_DISABLE 0
+#define GPIO_INTR_DISABLE 0
+typedef struct {
+    uint64_t pin_bit_mask;
+    int mode, pull_down_en, pull_up_en, intr_type;
+} gpio_config_t;
+int gpio_config(const gpio_config_t *config);
+int gpio_get_level(int pin);
+int gpio_hold_en(int pin);
+int gpio_hold_dis(int pin);
+void gpio_deep_sleep_hold_en(void);
+void gpio_deep_sleep_hold_dis(void);
+#endif
diff --git a/tests/include/esp_idf_version.h b/tests/include/esp_idf_version.h
new file mode 100644
index 0000000..278cbc0
--- /dev/null
+++ b/tests/include/esp_idf_version.h
@@ -0,0 +1,4 @@
+#define ESP_IDF_VERSION_VAL(a,b,c) (((a)<<16)|((b)<<8)|(c))
+#ifndef ESP_IDF_VERSION
+#define ESP_IDF_VERSION ESP_IDF_VERSION_VAL(6,0,0)
+#endif
diff --git a/tests/include/esp_sleep.h b/tests/include/esp_sleep.h
new file mode 100644
index 0000000..f9fd944
--- /dev/null
+++ b/tests/include/esp_sleep.h
@@ -0,0 +1,7 @@
+#include "mock.h"
+#define ESP_GPIO_WAKEUP_GPIO_HIGH 1
+#define ESP_SLEEP_WAKEUP_GPIO 7
+int esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(uint64_t mask, int mode);
+int esp_deep_sleep_enable_gpio_wakeup(uint64_t mask, int mode);
+int esp_sleep_get_wakeup_cause(void);
+void esp_deep_sleep_start(void);
diff --git a/tests/include/sdkconfig.h b/tests/include/sdkconfig.h
new file mode 100644
index 0000000..b2b16bc
--- /dev/null
+++ b/tests/include/sdkconfig.h
@@ -0,0 +1,9 @@
+#ifndef TEST_SDKCONFIG_H
+#define TEST_SDKCONFIG_H
+#ifndef TEST_PRESENCE_DISABLED
+#define CONFIG_HOMEKEY_PRESENCE_ENABLED 1
+#define CONFIG_HOMEKEY_PRESENCE_GPIO 1
+#define CONFIG_HOMEKEY_PRESENCE_ABSENCE_MS 1000
+#define CONFIG_HOMEKEY_PRESENCE_BOOT_GRACE_MS 10000
+#endif
+#endif
diff --git a/tests/pn532_test.c b/tests/pn532_test.c
index e02b95b..67f3dc8 100644
--- a/tests/pn532_test.c
+++ b/tests/pn532_test.c
@@ -7,6 +7,14 @@ static unsigned writes;
 static uint8_t last_tx[300];
 static size_t last_tx_len;
 static uint8_t tx_history[16][300];
+static bool held, deep_hold, sleep_requested;
+static bool auto_reply;
+static void response(const uint8_t *d, uint8_t n);
+bool presence_sleep_requested(void) { return sleep_requested; }
+int gpio_hold_en(int pin) { assert(pin == PN532_TX_GPIO); held = true; return ESP_OK; }
+int gpio_hold_dis(int pin) { assert(pin == PN532_TX_GPIO); held = false; return ESP_OK; }
+void gpio_deep_sleep_hold_en(void) { deep_hold = true; }
+void gpio_deep_sleep_hold_dis(void) { deep_hold = false; }
 bool relay_ecp_group(uint8_t out[8]) { (void)out; return false; }
 int64_t esp_timer_get_time(void) { return now; }
 void vTaskDelay(TickType_t t) { now += t * 1000; }
@@ -36,6 +44,20 @@ int uart_write_bytes(int u, const void *d, size_t n)
 	assert(writes < 16);
 	memcpy(tx_history[writes], d, n);
 	++writes;
+	if (auto_reply && n > 2) {
+		const uint8_t *frame = d;
+		if (frame[0] == 0x55)
+			frame += 5;
+		uint8_t cmd = frame[6];
+		uint8_t reply[] = {0xd5, (uint8_t)(cmd + 1), 0, 0, 0, 0};
+		if (cmd == 0x02) {
+			assert(now >= 10000); /* Explicit oscillator delay preceded firmware. */
+			reply[2] = 0x32;
+			response(reply, 6);
+		} else {
+			response(reply, cmd == 0x16 ? 3 : 2);
+		}
+	}
 	return (int)n;
 }
 int uart_wait_tx_done(int u, TickType_t t)
@@ -80,6 +102,8 @@ static void reset(void)
 	total = pos = 0;
 	now = 0;
 	writes = 0;
+	held = deep_hold = false;
+	auto_reply = sleep_requested = false;
 }
 static void append(const uint8_t *d, size_t n)
 {
@@ -202,6 +226,42 @@ int main(void)
 	response(rf, 2);
 	assert(!broadcast_ecp(gid));
 	assert(writes == 5);
+	/* Deep sleep is allowed only after a complete success response. */
+	const uint8_t sleeping[] = {0xd5, 0x17, 0};
+	reset();
+	response(rf, 2);
+	response(sleeping, 3);
+	assert(power_down_reader());
+	assert(held && deep_hold && writes == 2);
+	assert(tx_history[0][6] == 0x32 && tx_history[0][8] == 0);
+	assert(tx_history[1][6] == 0x16 && tx_history[1][7] == 0x10);
+	assert(now >= 10000);
+	reset();
+	response(rf, 2);
+	uint8_t refused[] = {0xd5, 0x17, 0x27};
+	response(refused, 3);
+	assert(!power_down_reader());
+	assert(!held && !deep_hold);
+	reset();
+	response(rf, 2);
+	response(sleeping, 2); /* Truncated status must not authorize sleep. */
+	assert(!power_down_reader());
+	assert(!held && !deep_hold);
+	reset();
+	response(rf, 2); /* PowerDown response lost. */
+	assert(!power_down_reader());
+	assert(!held && !deep_hold);
+	pn532_uart_init();
+	assert(!held && !deep_hold);
+	reset();
+	auto_reply = true;
+	assert(initialize_reader());
+	assert(writes == 4 && tx_history[0][0] == 0x55 && tx_history[0][1] == 0x55);
+	reset();
+	auto_reply = sleep_requested = true;
+	start_polling(NULL); /* Absence path returns before any card callback. */
+	assert(held && deep_hold && writes == 6);
+	assert(tx_history[5][6] == 0x16);
 	puts("PN532: ECP frame/CRC-A, raw transmit and expected timeout; startup prefix preserved; APDU >64 bytes; status/chaining rejection; fragmented "
 		 "frames, checksums, timeout and UID parsing passed");
 }
diff --git a/tests/presence_test.c b/tests/presence_test.c
new file mode 100644
index 0000000..8ace3d9
--- /dev/null
+++ b/tests/presence_test.c
@@ -0,0 +1,66 @@
+#include <stdio.h>
+#include "../esp32/main/presence.c"
+static int64_t now;
+static int level, cause;
+static unsigned armed, sleeps;
+int64_t esp_timer_get_time(void) { return now; }
+int gpio_config(const gpio_config_t *c)
+{
+    assert(c->pin_bit_mask == (1ULL << 5));
+    assert(c->mode == GPIO_MODE_INPUT && c->pull_down_en);
+    return ESP_OK;
+}
+int gpio_get_level(int pin) { assert(pin == 5); return level; }
+int esp_sleep_get_wakeup_cause(void) { return cause; }
+int esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(uint64_t mask, int mode)
+{
+    assert(mask == (1ULL << 5) && mode == ESP_GPIO_WAKEUP_GPIO_HIGH);
+    ++armed;
+    return ESP_OK;
+}
+int esp_deep_sleep_enable_gpio_wakeup(uint64_t mask, int mode)
+{
+    return esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(mask, mode);
+}
+void esp_deep_sleep_start(void) { assert(armed); ++sleeps; }
+int main(void)
+{
+    presence_init();
+#ifdef CONFIG_HOMEKEY_PRESENCE_ENABLED
+    assert(armed == 1);
+    assert(!presence_sleep_requested());
+    now = 9999999;
+    assert(!presence_sleep_requested());
+    now = 10000000;
+    assert(presence_sleep_requested());
+    level = 1;
+    assert(!presence_sleep_requested());
+    level = 0;
+    assert(!presence_sleep_requested());
+    now += 999999;
+    assert(!presence_sleep_requested());
+    level = 1; /* An observed presence cancels the previous absence. */
+    assert(!presence_sleep_requested());
+    level = 0;
+    assert(!presence_sleep_requested());
+    now += 1000000;
+    assert(presence_sleep_requested());
+    /* New GPIO-wake boot skips cold-start grace. */
+    now = 0;
+    absent_since = -1;
+    cause = ESP_SLEEP_WAKEUP_GPIO;
+    presence_init();
+    assert(!presence_sleep_requested());
+    now = 1000000;
+    assert(presence_sleep_requested());
+    level = 1; /* Wake source was armed before a late return of presence. */
+    presence_enter_sleep();
+    assert(sleeps == 1);
+#else
+    now = 100000000;
+    assert(!presence_sleep_requested());
+    presence_enter_sleep();
+    assert(armed == 0 && sleeps == 0);
+#endif
+    puts("Presence: boot grace, absence confirmation, return of presence, GPIO wake and disabled mode passed");
+}
diff --git a/tests/run.sh b/tests/run.sh
index 8cc7a98..74d19ac 100755
--- a/tests/run.sh
+++ b/tests/run.sh
@@ -3,7 +3,11 @@ set -eu
 cd "$(dirname "$0")"
 build_dir=$(mktemp -d)
 trap 'rm -rf "$build_dir"' EXIT
-for name in pn532 relay; do
+for name in pn532 relay presence; do
 	cc -std=c11 -Wall -Wextra -Werror -fsanitize=address,undefined -g -Iinclude "${name}_test.c" -o "$build_dir/$name"
 	"$build_dir/$name"
 done
+for config in '-DESP_IDF_VERSION=0x050401' '-DTEST_PRESENCE_DISABLED'; do
+	cc -std=c11 -Wall -Wextra -Werror -fsanitize=address,undefined -g -Iinclude "$config" presence_test.c -o "$build_dir/presence-config"
+	"$build_dir/presence-config"
+done