penguin/homekey-relay

homekey-relay

commit 40b73ceba1a11cc118a5ea639d190fcb8e32ac3a

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-04T12:50:36Z
subjectAdded PONG response
commit 40b73ceba1a11cc118a5ea639d190fcb8e32ac3a
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-10-04T12:50:36Z

    Added PONG response
---
 .gitignore                       |   1 +
 README.md                        |  10 +
 esp32/main/CMakeLists.txt        |  29 ++-
 esp32/main/main.c                | 260 ++++++++++++------------
 esp32/main/pin_test.c            |  78 ++++++++
 esp32/main/{pnc32.c => pnc532.c} | 130 ++++++++++--
 esp32/main/pnc532_test.c         | 418 +++++++++++++++++++++++++++++++++++++++
 7 files changed, 775 insertions(+), 151 deletions(-)

diff --git a/.gitignore b/.gitignore
index 3d72984..5d0ddbd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
 # ESP-IDF
 esp32/build/
+esp32/build-*/
 esp32/sdkconfig
 esp32/sdkconfig.old
 
diff --git a/README.md b/README.md
index 15bb643..e9cd928 100644
--- a/README.md
+++ b/README.md
@@ -74,3 +74,13 @@ byte 4..7   mock UID = DE AD BE EF
 LIBNFC_DEVICE=pn532_uart:/dev/cu.usbserial-8310 nfc-list
 LIBNFC_DEVICE=pn532_uart:/dev/cu.usbserial-8310 nfc-poll
 ```
+
+Test pins
+```
+idf.py -B build-pin-test -D PIN_TEST=ON build flash monitor
+```
+
+Test pnc532
+```
+idf.py -B build-pnc532-test -D PNC532_TEST=ON build flash monitor
+```
diff --git a/esp32/main/CMakeLists.txt b/esp32/main/CMakeLists.txt
index 661faba..e228784 100644
--- a/esp32/main/CMakeLists.txt
+++ b/esp32/main/CMakeLists.txt
@@ -1,5 +1,24 @@
-idf_component_register(
-    SRCS "main.c" "pnc32.c"
-    INCLUDE_DIRS "."
-    PRIV_REQUIRES bt nvs_flash driver esp_timer esp_hw_support esp_driver_uart
-)
+option(PIN_TEST "Build UART loopback test" OFF)
+option(PNC532_TEST "Build PNC532 test" OFF)
+
+if(PIN_TEST)
+    idf_component_register(
+        SRCS "pin_test.c"
+        INCLUDE_DIRS "."
+        PRIV_REQUIRES driver esp_driver_uart
+    )
+elseif(PNC532_TEST)
+    idf_component_register(
+        SRCS "pnc532_test.c"
+        INCLUDE_DIRS "."
+        PRIV_REQUIRES nvs_flash driver esp_timer
+                      esp_hw_support esp_driver_uart
+    )
+else()
+    idf_component_register(
+        SRCS "main.c" "pnc532.c"
+        INCLUDE_DIRS "."
+        PRIV_REQUIRES bt nvs_flash driver esp_timer
+                      esp_hw_support esp_driver_uart
+    )
+endif()
diff --git a/esp32/main/main.c b/esp32/main/main.c
index 2f6ba1c..da97b8c 100644
--- a/esp32/main/main.c
+++ b/esp32/main/main.c
@@ -1,164 +1,170 @@
 #include <assert.h>
-
+#include <string.h>
 #include "esp_err.h"
 #include "esp_log.h"
-#include "esp_random.h"
 #include "nvs_flash.h"
-#include "freertos/FreeRTOS.h"
-#include "freertos/queue.h"
 #include "nimble/nimble_port.h"
 #include "nimble/nimble_port_freertos.h"
-#include "nimble/nimble_npl.h"
 #include "host/ble_gap.h"
+#include "host/ble_gatt.h"
 #include "host/ble_hs.h"
 #include "host/util/util.h"
 #include "services/gap/ble_svc_gap.h"
-
+#include "services/gatt/ble_svc_gatt.h"
 #include "reader.h"
 
 #define DEVICE_NAME "homekey-reader"
-#define ADV_DURATION_MS 1000
-#define CARD_QUEUE_LENGTH 8
-
 static const char *TAG = "homekey-reader";
-static QueueHandle_t card_queue;
-static struct ble_npl_event card_event;
-
-/* The fields below belong exclusively to the NimBLE host task. */
+/* UUID bytes are least-significant first in NimBLE.
+ * Service:		7a6b0001-5b21-4f36-8e5d-9c3a26d74210
+ * Characteristic: 7a6b0002-5b21-4f36-8e5d-9c3a26d74210
+ */
+static const ble_uuid128_t service_uuid = BLE_UUID128_INIT(
+	0x10,0x42,0xd7,0x26,0x3a,0x9c,0x5d,0x8e,0x36,0x4f,0x21,0x5b,0x01,0x00,0x6b,0x7a);
+static const ble_uuid128_t ping_uuid = BLE_UUID128_INIT(
+	0x10,0x42,0xd7,0x26,0x3a,0x9c,0x5d,0x8e,0x36,0x4f,0x21,0x5b,0x02,0x00,0x6b,0x7a);
 static uint8_t own_addr_type;
-static uint16_t event_seq;
-static bool ble_ready;
-static bool advertising;
-static bool have_pending;
-static nfc_card_t pending;
+static uint16_t connection = BLE_HS_CONN_HANDLE_NONE;
+/* Accessed only by the NimBLE host task. */
+static uint8_t reply[8];
+static bool have_reply;
 
-static void advertise_next(void);
-
-static int gap_event(struct ble_gap_event *event, void *arg)
+static int ping_access(uint16_t conn, uint16_t attr,
+					   struct ble_gatt_access_ctxt *ctxt, void *arg)
 {
-    (void)arg;
-    if (event->type == BLE_GAP_EVENT_ADV_COMPLETE) {
-        advertising = false;
-        have_pending = false;
-        ESP_LOGI(TAG, "card advertisement complete");
-        /* Only start again if a different card event is waiting. */
-        advertise_next();
-    }
-    return 0;
+	(void)conn; (void)attr; (void)arg;
+	if (ctxt->op == BLE_GATT_ACCESS_OP_WRITE_CHR) {
+		uint8_t request[8];
+		if (OS_MBUF_PKTLEN(ctxt->om) != sizeof(request))
+			return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
+		if (os_mbuf_copydata(ctxt->om, 0, sizeof(request), request) != 0)
+			return BLE_ATT_ERR_UNLIKELY;
+		if (memcmp(request, "PING", 4) != 0)
+			return BLE_ATT_ERR_VALUE_NOT_ALLOWED;
+		memcpy(reply, request, sizeof(reply));
+		memcpy(reply, "PONG", 4);
+		have_reply = true;
+		ESP_LOGI(TAG, "BLE PING received; PONG ready");
+		return 0;
+	}
+	if (ctxt->op == BLE_GATT_ACCESS_OP_READ_CHR) {
+		if (!have_reply) return 0; /* Empty until the first PING. */
+		return os_mbuf_append(ctxt->om, reply, sizeof(reply)) == 0
+			? 0 : BLE_ATT_ERR_INSUFFICIENT_RES;
+	}
+	return BLE_ATT_ERR_UNLIKELY;
 }
-
-static void advertise_next(void)
+static const struct ble_gatt_svc_def services[] = {
+	{ .type = BLE_GATT_SVC_TYPE_PRIMARY, .uuid = &service_uuid.u,
+	  .characteristics = (struct ble_gatt_chr_def[]) {
+		  { .uuid = &ping_uuid.u, .access_cb = ping_access,
+			.flags = BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_WRITE },
+		  {0}
+	  } },
+	{0}
+};
+static void advertise(void);
+static int gap_event(struct ble_gap_event *event, void *arg)
 {
-    if (!ble_ready || advertising) {
-        return;
-    }
-    while (have_pending || xQueueReceive(card_queue, &pending, 0) == pdTRUE) {
-        if (!have_pending) {
-            have_pending = true;
-            ++event_seq;
-        }
-        uint8_t adv[31];
-        size_t adv_len = build_card_adv(adv, &pending, event_seq);
-        assert(adv_len > 0);
-
-        int rc = ble_gap_adv_set_data(adv, (int)adv_len);
-        if (rc != 0) {
-            ESP_LOGE(TAG, "setting advertisement failed: %d; event dropped", rc);
-            have_pending = false;
-            continue;
-        }
-        struct ble_gap_adv_params params = {0};
-        params.conn_mode = BLE_GAP_CONN_MODE_NON;
-        params.disc_mode = BLE_GAP_DISC_MODE_NON;
-        params.itvl_min = BLE_GAP_ADV_ITVL_MS(100);
-        params.itvl_max = BLE_GAP_ADV_ITVL_MS(150);
-
-        rc = ble_gap_adv_start(own_addr_type, NULL, ADV_DURATION_MS,
-                               &params, gap_event, NULL);
-        if (rc != 0) {
-            ESP_LOGE(TAG, "starting advertisement failed: %d; event dropped", rc);
-            have_pending = false;
-            continue;
-        }
-        advertising = true;
-        ESP_LOGI(TAG, "advertising card: event_seq=%u uid_len=%u",
-                 event_seq, pending.uid_len);
-        ESP_LOG_BUFFER_HEX_LEVEL(TAG, pending.uid, pending.uid_len, ESP_LOG_INFO);
-        return;
-    }
+	(void)arg;
+	switch (event->type) {
+	case BLE_GAP_EVENT_CONNECT:
+		if (event->connect.status == 0) {
+			connection = event->connect.conn_handle;
+			have_reply = false;
+			ESP_LOGI(TAG, "BLE gateway connected");
+		} else advertise();
+		break;
+	case BLE_GAP_EVENT_DISCONNECT:
+		connection = BLE_HS_CONN_HANDLE_NONE;
+		have_reply = false;
+		ESP_LOGI(TAG, "BLE gateway disconnected: %d", event->disconnect.reason);
+		advertise();
+		break;
+	case BLE_GAP_EVENT_ADV_COMPLETE:
+		if (connection == BLE_HS_CONN_HANDLE_NONE) advertise();
+		break;
+	default: break;
+	}
+	return 0;
 }
-
-static void on_card_event(struct ble_npl_event *event)
+static void advertise(void)
 {
-    (void)event;
-    advertise_next();
+	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;
+	fields.num_uuids128 = 1;
+	fields.uuids128_is_complete = 1;
+	int rc = ble_gap_adv_set_fields(&fields);
+	if (rc != 0) { ESP_LOGE(TAG, "advertisement fields: %d", rc); return; }
+	struct ble_hs_adv_fields scan = {0};
+	scan.name = (uint8_t *)DEVICE_NAME;
+	scan.name_len = strlen(DEVICE_NAME);
+	scan.name_is_complete = 1;
+	rc = ble_gap_adv_rsp_set_fields(&scan);
+	if (rc != 0) { ESP_LOGE(TAG, "scan response: %d", rc); return; }
+	struct ble_gap_adv_params params = {0};
+	params.conn_mode = BLE_GAP_CONN_MODE_UND;
+	params.disc_mode = BLE_GAP_DISC_MODE_GEN;
+	params.itvl_min = BLE_GAP_ADV_ITVL_MS(100);
+	params.itvl_max = BLE_GAP_ADV_ITVL_MS(150);
+	rc = ble_gap_adv_start(own_addr_type, NULL, BLE_HS_FOREVER,
+						   &params, gap_event, NULL);
+	if (rc != 0) ESP_LOGE(TAG, "advertisement start: %d", rc);
 }
-
-/* Called from PN532 task: copy the UID, then notify the host task.
- * No BLE calls or shared advertisement buffers cross task boundaries.
+/* NFC task stays independent. Card events are logged for now;
+ * the old non-connectable UID advertisements are replaced by GATT.
  */
 static bool report_card(const nfc_card_t *card)
 {
-    if (xQueueSend(card_queue, card, 0) != pdTRUE) {
-        ESP_LOGW(TAG, "card queue full; retrying while card is present");
-        return false;
-    }
-    ble_npl_eventq_put(nimble_port_get_dflt_eventq(), &card_event);
-    return true;
+	ESP_LOGI(TAG, "NFC card detected; uid_len=%u", card->uid_len);
+	ESP_LOG_BUFFER_HEX_LEVEL(TAG, card->uid, card->uid_len, ESP_LOG_INFO);
+	return true;
 }
-
 static void on_reset(int reason)
 {
-    ble_ready = false;
-    advertising = false;
-    /* Keep a partially advertised event and its sequence for resync. */
-    ESP_LOGE(TAG, "NimBLE reset, reason=%d", reason);
+	connection = BLE_HS_CONN_HANDLE_NONE;
+	have_reply = false;
+	ESP_LOGE(TAG, "NimBLE reset: %d", reason);
 }
-
 static void on_sync(void)
 {
-    uint8_t addr[6];
-    int rc = ble_hs_util_ensure_addr(0);
-    assert(rc == 0);
-    rc = ble_hs_id_infer_auto(0, &own_addr_type);
-    assert(rc == 0);
-    rc = ble_hs_id_copy_addr(own_addr_type, addr, NULL);
-    assert(rc == 0);
-    ESP_LOGI(TAG, "BLE address %02X:%02X:%02X:%02X:%02X:%02X",
-             addr[5], addr[4], addr[3], addr[2], addr[1], addr[0]);
-    ble_ready = true;
-    advertise_next();
+	uint8_t addr[6];
+	int rc = ble_hs_util_ensure_addr(0);
+	assert(rc == 0);
+	rc = ble_hs_id_infer_auto(0, &own_addr_type);
+	assert(rc == 0);
+	rc = ble_hs_id_copy_addr(own_addr_type, addr, NULL);
+	assert(rc == 0);
+	ESP_LOGI(TAG, "BLE address %02X:%02X:%02X:%02X:%02X:%02X",
+			 addr[5],addr[4],addr[3],addr[2],addr[1],addr[0]);
+	advertise();
 }
-
-static void host_task(void *param)
+static void host_task(void *arg)
 {
-    (void)param;
-    nimble_port_run();
-    nimble_port_freertos_deinit();
+	(void)arg;
+	nimble_port_run();
+	nimble_port_freertos_deinit();
 }
-
 void app_main(void)
 {
-    esp_err_t err = nvs_flash_init();
-    if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
-        ESP_ERROR_CHECK(nvs_flash_erase());
-        err = nvs_flash_init();
-    }
-    ESP_ERROR_CHECK(err);
-    ESP_ERROR_CHECK(nimble_port_init());
-
-    card_queue = xQueueCreate(CARD_QUEUE_LENGTH, sizeof(nfc_card_t));
-    assert(card_queue != NULL);
-    ble_npl_event_init(&card_event, on_card_event, NULL);
-    /* Reduce duplicate sequence values following a reboot; not a unique ID. */
-    event_seq = (uint16_t)esp_random();
-    ble_hs_cfg.reset_cb = on_reset;
-    ble_hs_cfg.sync_cb = on_sync;
-    ble_svc_gap_init();
-    int rc = ble_svc_gap_device_name_set(DEVICE_NAME);
-    assert(rc == 0);
-    nimble_port_freertos_init(host_task);
-
-    /* app_main is already a FreeRTOS task; UART waits yield to NimBLE. */
-    start_polling(report_card);
+	esp_err_t err = nvs_flash_init();
+	if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
+		ESP_ERROR_CHECK(nvs_flash_erase());
+		err = nvs_flash_init();
+	}
+	ESP_ERROR_CHECK(err);
+	ESP_ERROR_CHECK(nimble_port_init());
+	ble_hs_cfg.reset_cb = on_reset;
+	ble_hs_cfg.sync_cb = on_sync;
+	ble_svc_gap_init();
+	ble_svc_gatt_init();
+	int rc = ble_svc_gap_device_name_set(DEVICE_NAME);
+	assert(rc == 0);
+	rc = ble_gatts_count_cfg(services);
+	assert(rc == 0);
+	rc = ble_gatts_add_svcs(services);
+	assert(rc == 0);
+	nimble_port_freertos_init(host_task);
+	start_polling(report_card);
 }
diff --git a/esp32/main/pin_test.c b/esp32/main/pin_test.c
new file mode 100644
index 0000000..74fe746
--- /dev/null
+++ b/esp32/main/pin_test.c
@@ -0,0 +1,78 @@
+#include <string.h>
+
+#include "driver/uart.h"
+#include "esp_err.h"
+#include "esp_log.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/task.h"
+
+#define TEST_UART UART_NUM_1
+#define TX_PIN 4
+#define RX_PIN 3
+
+static const char *TAG = "uart-loopback";
+
+void app_main(void)
+{
+    const uart_config_t config = {
+        .baud_rate = 115200,
+        .data_bits = UART_DATA_8_BITS,
+        .parity = UART_PARITY_DISABLE,
+        .stop_bits = UART_STOP_BITS_1,
+        .flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
+        .source_clk = UART_SCLK_DEFAULT,
+    };
+
+    ESP_ERROR_CHECK(uart_param_config(TEST_UART, &config));
+    ESP_ERROR_CHECK(uart_set_pin(
+        TEST_UART, TX_PIN, RX_PIN,
+        UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE));
+    ESP_ERROR_CHECK(uart_driver_install(
+        TEST_UART, 256, 0, 0, NULL, 0));
+
+    const uint8_t tx[] = {
+        0x55, 0xaa, 0x00, 0xff, 0x02, 0xfe, 0xd4, 0x2a
+    };
+
+    for (;;) {
+        uint8_t rx[sizeof(tx)] = {0};
+        size_t received = 0;
+
+        ESP_ERROR_CHECK(uart_flush_input(TEST_UART));
+
+        int written = uart_write_bytes(TEST_UART, tx, sizeof(tx));
+        ESP_LOGI(TAG, "TX: %d bytes", written);
+        ESP_LOG_BUFFER_HEX_LEVEL(TAG, tx, sizeof(tx), ESP_LOG_INFO);
+
+        /* Accumulate reads: UART may return fewer bytes than requested. */
+        while (received < sizeof(rx)) {
+            int n = uart_read_bytes(
+                TEST_UART,
+                rx + received,
+                sizeof(rx) - received,
+                pdMS_TO_TICKS(500));
+
+            if (n <= 0) {
+                ESP_LOGW(TAG, "read stopped: %d", n);
+                break;
+            }
+            received += (size_t)n;
+        }
+
+        ESP_LOGI(TAG, "RX: %u bytes", (unsigned)received);
+        if (received > 0) {
+            ESP_LOG_BUFFER_HEX_LEVEL(
+                TAG, rx, received, ESP_LOG_INFO);
+        }
+
+        if (written == sizeof(tx) &&
+            received == sizeof(tx) &&
+            memcmp(tx, rx, sizeof(tx)) == 0) {
+            ESP_LOGI(TAG, "PASS: RX = TX");
+        } else {
+            ESP_LOGE(TAG, "FAIL: RX != TX");
+        }
+
+        vTaskDelay(pdMS_TO_TICKS(2000));
+    }
+}
\ No newline at end of file
diff --git a/esp32/main/pnc32.c b/esp32/main/pnc532.c
similarity index 66%
rename from esp32/main/pnc32.c
rename to esp32/main/pnc532.c
index 0e220e5..d643767 100644
--- a/esp32/main/pnc32.c
+++ b/esp32/main/pnc532.c
@@ -9,8 +9,8 @@
 #include "reader.h"
 
 #define PN532_UART UART_NUM_1
-#define PN532_TX_GPIO 4
-#define PN532_RX_GPIO 3
+#define PN532_TX_GPIO 3
+#define PN532_RX_GPIO 4
 #define PN532_BAUD 115200
 #define COMMAND_TIMEOUT_MS 1000
 #define POLL_INTERVAL_MS 300
@@ -69,41 +69,84 @@ static bool read_exact(uint8_t *data, size_t len, int64_t deadline)
 /* Returns 0 for ACK, positive for a normal frame, -1 for malformed/timeout.
  * Extended frames are unnecessary for these small commands and are rejected.
  */
+static bool read_logged(uint8_t *buffer, size_t len,
+                        int64_t deadline, const char *stage)
+{
+    if (!read_exact(buffer, len, deadline)) {
+        ESP_LOGW(TAG, "RX failed at %s: wanted %u bytes",
+                 stage, (unsigned)len);
+        return false;
+    }
+
+#ifdef FRAME_DEBUG
+    ESP_LOGI(TAG, "RX %s (%u bytes)", stage, (unsigned)len);
+    ESP_LOG_BUFFER_HEX_LEVEL(TAG, buffer, len, ESP_LOG_INFO);
+#endif
+    return true;
+}
+
 static int read_frame(uint8_t *payload, size_t capacity, int64_t deadline)
 {
     uint8_t prefix[3] = {0};
     do {
         prefix[0] = prefix[1];
         prefix[1] = prefix[2];
-        if (!read_exact(&prefix[2], 1, deadline)) {
+        if (!read_logged(&prefix[2], 1, deadline, "preamble byte")) {
             return -1;
         }
-    } while (prefix[0] != 0 || prefix[1] != 0 || prefix[2] != 0xff);
+    } while (prefix[0] != 0 ||
+             prefix[1] != 0 ||
+             prefix[2] != 0xff);
 
     uint8_t lengths[2];
-    if (!read_exact(lengths, 2, deadline)) {
+    if (!read_logged(lengths, sizeof(lengths), deadline, "LEN/LCS")) {
         return -1;
     }
+
     if (lengths[0] == 0 && lengths[1] == 0xff) {
         uint8_t postamble;
-        return read_exact(&postamble, 1, deadline) && postamble == 0 ? 0 : -1;
+        if (!read_logged(&postamble, 1, deadline, "ACK postamble")) {
+            return -1;
+        }
+        if (postamble != 0) {
+            ESP_LOGW(TAG, "invalid ACK postamble");
+            return -1;
+        }
+        return 0;
     }
+
     size_t len = lengths[0];
-    if (len == 0 || (uint8_t)(lengths[0] + lengths[1]) != 0 || len > capacity) {
+    if (len == 0 ||
+        (uint8_t)(lengths[0] + lengths[1]) != 0 ||
+        len > capacity) {
+        ESP_LOGW(TAG, "invalid length: LEN=%u LCS=0x%02X capacity=%u",
+                 (unsigned)len, lengths[1], (unsigned)capacity);
         return -1;
     }
-    if (!read_exact(payload, len, deadline)) {
+
+    if (!read_logged(payload, len, deadline, "payload")) {
         return -1;
     }
+
     uint8_t tail[2];
-    if (!read_exact(tail, 2, deadline) || tail[1] != 0) {
+    if (!read_logged(tail, sizeof(tail), deadline, "DCS/postamble")) {
+        return -1;
+    }
+    if (tail[1] != 0) {
+        ESP_LOGW(TAG, "invalid postamble: 0x%02X", tail[1]);
         return -1;
     }
+
     uint8_t checksum = tail[0];
     for (size_t i = 0; i < len; ++i) {
         checksum += payload[i];
     }
-    return checksum == 0 ? (int)len : -1;
+    if (checksum != 0) {
+        ESP_LOGW(TAG, "invalid checksum: sum=0x%02X", checksum);
+        return -1;
+    }
+
+    return (int)len;
 }
 
 /* Normal frame builder. cmd includes command code, excludes host TFI (D4). */
@@ -113,6 +156,7 @@ static int command(const uint8_t *cmd, size_t cmd_len, uint8_t response[128])
     if (cmd_len == 0 || cmd_len > sizeof(frame) - 8) {
         return -1;
     }
+
     size_t len = cmd_len + 1;
     frame[0] = 0;
     frame[1] = 0;
@@ -121,6 +165,7 @@ static int command(const uint8_t *cmd, size_t cmd_len, uint8_t response[128])
     frame[4] = (uint8_t)(0 - len);
     frame[5] = 0xd4;
     memcpy(frame + 6, cmd, cmd_len);
+
     uint8_t checksum = 0xd4;
     for (size_t i = 0; i < cmd_len; ++i) {
         checksum += cmd[i];
@@ -128,23 +173,53 @@ static int command(const uint8_t *cmd, size_t cmd_len, uint8_t response[128])
     frame[6 + cmd_len] = (uint8_t)(0 - checksum);
     frame[7 + cmd_len] = 0;
 
+#ifdef FRAME_DEBUG
+    ESP_LOGI(TAG, "TX command 0x%02X (%u bytes)",
+             cmd[0], (unsigned)(cmd_len + 8));
+    ESP_LOG_BUFFER_HEX_LEVEL(TAG, frame, cmd_len + 8, ESP_LOG_INFO);
+#endif
+
     if (!write_bytes(frame, cmd_len + 8)) {
+        ESP_LOGE(TAG, "TX write failed");
         goto failed;
     }
-    int64_t deadline = esp_timer_get_time() + COMMAND_TIMEOUT_MS * 1000;
+
+    int64_t deadline =
+        esp_timer_get_time() + COMMAND_TIMEOUT_MS * 1000;
     int n;
+
     do {
         n = read_frame(response, 128, deadline);
+#ifdef FRAME_DEBUG
+        if (n > 0) {
+            ESP_LOGI(TAG, "RX response (%d bytes)", n);
+            ESP_LOG_BUFFER_HEX_LEVEL(TAG, response, n, ESP_LOG_INFO);
+        } else if (n == 0) {
+            ESP_LOGI(TAG, "RX ACK");
+        } else {
+            ESP_LOGW(TAG, "RX failed or timed out: %d", n);
+        }
+#else
+        if (n < 0) {
+            ESP_LOGW(TAG, "RX failed or timed out: %d", n);
+        }
+#endif
     } while (n == 0); /* ACK is not the command response. */
-    if (n >= 2 && response[0] == 0xd5 && response[1] == (uint8_t)(cmd[0] + 1)) {
-        ESP_LOG_BUFFER_HEX_LEVEL(TAG, response, n, ESP_LOG_DEBUG);
+
+    if (n >= 2 &&
+        response[0] == 0xd5 &&
+        response[1] == (uint8_t)(cmd[0] + 1)) {
         return n;
     }
 
 failed:
     /* Host ACK aborts a pending PN532 command. Drain before the next command. */
     ESP_LOGW(TAG, "command 0x%02X failed or timed out", cmd[0]);
+
+    ESP_LOGI(TAG, "TX abort ACK (%u bytes)", (unsigned)sizeof(ack));
+    ESP_LOG_BUFFER_HEX_LEVEL(TAG, ack, sizeof(ack), ESP_LOG_INFO);
     (void)write_bytes(ack, sizeof(ack));
+
     vTaskDelay(pdMS_TO_TICKS(20));
     (void)uart_flush_input(PN532_UART);
     return -1;
@@ -155,25 +230,42 @@ static bool initialize_reader(void)
     static const uint8_t wakeup[] = {0x55, 0x55, 0, 0, 0};
     static const uint8_t firmware[] = {0x02};
     static const uint8_t sam[] = {0x14, 0x01, 0x14, 0x01};
-    /* RFConfiguration / MaxRetries: preserve ATR/PSL defaults, scan once. */
     static const uint8_t retries[] = {0x32, 0x05, 0xff, 0x01, 0x00};
     uint8_t response[128];
+
+    /* Discard stale input before starting this attempt. */
+    ESP_ERROR_CHECK(uart_flush_input(PN532_UART));
+
+    ESP_LOGI(TAG, "TX wakeup");
+    ESP_LOG_BUFFER_HEX_LEVEL(TAG, wakeup, sizeof(wakeup), ESP_LOG_INFO);
     if (!write_bytes(wakeup, sizeof(wakeup))) {
+        ESP_LOGE(TAG, "wakeup write failed");
         return false;
     }
-    vTaskDelay(pdMS_TO_TICKS(10));
-    (void)uart_flush_input(PN532_UART);
+
+    /* Conservative diagnostic delay, not a specified minimum. */
+    vTaskDelay(pdMS_TO_TICKS(100));
+
+    /* Put the reader into normal mode before querying firmware. */
+    if (command(sam, sizeof(sam), response) != 2) {
+        ESP_LOGE(TAG, "SAMConfiguration failed");
+        return false;
+    }
+
     int n = command(firmware, sizeof(firmware), response);
     if (n != 6 || response[2] != 0x32) {
-        ESP_LOGE(TAG, "invalid PN532 firmware response");
+        ESP_LOGE(TAG, "invalid firmware response: length=%d", n);
         return false;
     }
+
     ESP_LOGI(TAG, "PN532 firmware %u.%u, support=0x%02X",
              response[3], response[4], response[5]);
-    if (command(sam, sizeof(sam), response) != 2 ||
-        command(retries, sizeof(retries), response) != 2) {
+
+    if (command(retries, sizeof(retries), response) != 2) {
+        ESP_LOGE(TAG, "RFConfiguration failed");
         return false;
     }
+
     return true;
 }
 
diff --git a/esp32/main/pnc532_test.c b/esp32/main/pnc532_test.c
new file mode 100644
index 0000000..d561dde
--- /dev/null
+++ b/esp32/main/pnc532_test.c
@@ -0,0 +1,418 @@
+#include <stdio.h>
+#include <string.h>
+
+#include "driver/uart.h"
+#include "esp_log.h"
+#include "esp_err.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/task.h"
+
+#define PN532_UART      UART_NUM_1
+#define PN532_TX_GPIO   3
+#define PN532_RX_GPIO   4
+#define PN532_BAUD      115200
+
+static const char *TAG = "pn532";
+
+static void dump_hex(const char *prefix, const uint8_t *buf, size_t len)
+{
+    printf("%s", prefix);
+
+    for (size_t i = 0; i < len; i++) {
+        printf("%02X ", buf[i]);
+    }
+
+    printf("\n");
+}
+
+static void pn532_uart_init(void)
+{
+    const uart_config_t config = {
+        .baud_rate = PN532_BAUD,
+        .data_bits = UART_DATA_8_BITS,
+        .parity = UART_PARITY_DISABLE,
+        .stop_bits = UART_STOP_BITS_1,
+        .flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
+        .source_clk = UART_SCLK_DEFAULT,
+    };
+
+    ESP_ERROR_CHECK(uart_driver_install(
+        PN532_UART,
+        1024,
+        0,
+        0,
+        NULL,
+        0
+    ));
+
+    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
+    ));
+
+    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
+    );
+}
+
+static int pn532_read(uint8_t *buf, size_t size, int timeout_ms)
+{
+    return uart_read_bytes(
+        PN532_UART,
+        buf,
+        size,
+        pdMS_TO_TICKS(timeout_ms)
+    );
+}
+
+static void pn532_write(const uint8_t *buf, size_t len)
+{
+    dump_hex("TX: ", buf, len);
+
+    uart_write_bytes(
+        PN532_UART,
+        buf,
+        len
+    );
+
+    uart_wait_tx_done(
+        PN532_UART,
+        pdMS_TO_TICKS(100)
+    );
+}
+
+static void pn532_wakeup(void)
+{
+    /*
+     * PN532 HSU wake-up preamble.
+     *
+     * The PN532 manual specifies 55 55 followed by the
+     * normal synchronization sequence for waking from
+     * LowVbat mode.
+     */
+    static const uint8_t wakeup[] = {
+        0x55, 0x55,
+        0x00, 0x00, 0x00, 0x00, 0x00,
+        0xFF
+    };
+
+    ESP_LOGI(TAG, "sending wakeup");
+
+    pn532_write(wakeup, sizeof(wakeup));
+
+    vTaskDelay(pdMS_TO_TICKS(10));
+
+    uart_flush_input(PN532_UART);
+}
+
+static void pn532_get_firmware_version(void)
+{
+    static const uint8_t cmd[] = {
+        /* HSU wakeup */
+        0x55, 0x55,
+        0x00, 0x00, 0x00, 0x00, 0x00,
+
+        /* PN532 frame */
+        0xFF,
+        0x02, 0xFE,
+        0xD4, 0x02,
+        0x2A,
+        0x00
+    };
+
+    uint8_t buf[64];
+
+    uart_flush_input(PN532_UART);
+
+    ESP_LOGI(TAG, "GetFirmwareVersion");
+    pn532_write(cmd, sizeof(cmd));
+
+    int len = pn532_read(buf, sizeof(buf), 1000);
+
+    if (len <= 0) {
+        ESP_LOGE(TAG, "no response from PN532");
+        return;
+    }
+
+    dump_hex("RX: ", buf, len);
+
+    len = pn532_read(buf, sizeof(buf), 500);
+
+    if (len > 0) {
+        dump_hex("RX: ", buf, len);
+    }
+}
+
+static int pn532_read_response(uint8_t *buf, size_t size, int timeout_ms)
+{
+    int len = pn532_read(buf, size, timeout_ms);
+
+    if (len <= 0) {
+        return len;
+    }
+
+    dump_hex("RX: ", buf, len);
+    return len;
+}
+
+static void pn532_poll_card(void)
+{
+    /*
+     * InListPassiveTarget
+     *
+     * D4 4A 01 00
+     *    │  │  └── BrTy = 0x00: ISO14443A @ 106 kbps
+     *    │  └───── MaxTg = 1
+     *    └──────── command
+     *
+     * Complete PN532 frame:
+     *
+     * 00 00 FF 04 FC D4 4A 01 00 E1 00
+     */
+    static const uint8_t cmd[] = {
+        0x00, 0x00, 0xFF,
+        0x04, 0xFC,
+        0xD4, 0x4A, 0x01, 0x00,
+        0xE1,
+        0x00
+    };
+
+    uint8_t buf[128];
+
+    uart_flush_input(PN532_UART);
+
+    pn532_write(cmd, sizeof(cmd));
+
+    int len = pn532_read_response(
+        buf,
+        sizeof(buf),
+        1000
+    );
+
+    if (len <= 0) {
+        /*
+         * No card is normal.
+         */
+        return;
+    }
+
+    /*
+     * ACK:
+     *
+     * 00 00 FF 00 FF 00
+     *
+     * Often ACK + response arrive in one UART read.
+     *
+     * Find the response frame:
+     *
+     * 00 00 FF LEN LCS D5 4B ...
+     */
+    int frame = -1;
+
+    for (int i = 0; i + 6 < len; i++) {
+        if (
+            buf[i + 0] == 0x00 &&
+            buf[i + 1] == 0x00 &&
+            buf[i + 2] == 0xFF &&
+            buf[i + 5] == 0xD5 &&
+            buf[i + 6] == 0x4B
+        ) {
+            frame = i;
+            break;
+        }
+    }
+
+    /*
+     * Response may have arrived separately from ACK.
+     */
+    if (frame < 0) {
+        len = pn532_read_response(
+            buf,
+            sizeof(buf),
+            1000
+        );
+
+        if (len <= 0) {
+            return;
+        }
+
+        for (int i = 0; i + 6 < len; i++) {
+            if (
+                buf[i + 0] == 0x00 &&
+                buf[i + 1] == 0x00 &&
+                buf[i + 2] == 0xFF &&
+                buf[i + 5] == 0xD5 &&
+                buf[i + 6] == 0x4B
+            ) {
+                frame = i;
+                break;
+            }
+        }
+    }
+
+    if (frame < 0) {
+        ESP_LOGW(TAG, "no InListPassiveTarget response found");
+        return;
+    }
+
+    /*
+     * Payload after D5 4B:
+     *
+     * NbTg
+     *
+     * If NbTg > 0:
+     *
+     * Tg
+     * SENS_RES[2]
+     * SEL_RES
+     * NFCIDLength
+     * NFCID[]
+     */
+    int p = frame + 7;
+
+    if (p >= len) {
+        return;
+    }
+
+    uint8_t targets = buf[p++];
+
+    if (targets == 0) {
+        return;
+    }
+
+    if (p + 5 > len) {
+        ESP_LOGW(TAG, "short target response");
+        return;
+    }
+
+    uint8_t tg = buf[p++];
+
+    uint8_t sens_res_0 = buf[p++];
+    uint8_t sens_res_1 = buf[p++];
+
+    uint8_t sel_res = buf[p++];
+
+    uint8_t uid_len = buf[p++];
+
+    if (uid_len == 0 || uid_len > 10 || p + uid_len > len) {
+        ESP_LOGW(TAG, "invalid UID length: %u", uid_len);
+        return;
+    }
+
+    ESP_LOGI(
+        TAG,
+        "card: Tg=%u ATQA=%02X%02X SAK=%02X UID len=%u",
+        tg,
+        sens_res_0,
+        sens_res_1,
+        sel_res,
+        uid_len
+    );
+
+    printf("UID: ");
+
+    for (int i = 0; i < uid_len; i++) {
+        if (i) {
+            printf(":");
+        }
+
+        printf("%02X", buf[p + i]);
+    }
+
+    printf("\n");
+}
+
+static bool pn532_sam_config(void)
+{
+    /*
+     * HSU wake-up + SAMConfiguration
+     *
+     * D4 14 01 14 01
+     *       │  │  └── IRQ enabled
+     *       │  └───── timeout
+     *       └──────── normal mode
+     *
+     * DCS:
+     * D4 + 14 + 01 + 14 + 01 = FE
+     * checksum = 02
+     */
+    static const uint8_t cmd[] = {
+        /* HSU wake-up */
+        0x55, 0x55,
+        0x00, 0x00, 0x00, 0x00, 0x00,
+
+        /* PN532 frame */
+        0xFF,
+        0x05, 0xFB,
+        0xD4, 0x14, 0x01, 0x14, 0x01,
+        0x02,
+        0x00
+    };
+
+    uint8_t buf[64];
+
+    uart_flush_input(PN532_UART);
+
+    ESP_LOGI(TAG, "SAMConfiguration");
+    pn532_write(cmd, sizeof(cmd));
+
+    int len = pn532_read(buf, sizeof(buf), 1000);
+
+    if (len <= 0) {
+        ESP_LOGE(TAG, "SAMConfiguration: no response");
+        return false;
+    }
+
+    dump_hex("RX: ", buf, len);
+
+    /*
+     * Expected somewhere in the response:
+     *
+     * ACK:
+     * 00 00 FF 00 FF 00
+     *
+     * response:
+     * 00 00 FF 02 FE D5 15 16 00
+     */
+
+    return true;
+}
+
+void app_main(void)
+{
+    ESP_LOGI(TAG, "PN532 NFC reader");
+
+    pn532_uart_init();
+
+    vTaskDelay(pdMS_TO_TICKS(1000));
+
+    pn532_get_firmware_version();
+
+    vTaskDelay(pdMS_TO_TICKS(100));
+
+    if (!pn532_sam_config()) {
+        ESP_LOGE(TAG, "PN532 initialization failed");
+        return;
+    }
+
+    vTaskDelay(pdMS_TO_TICKS(100));
+
+    while (1) {
+        pn532_poll_card();
+        vTaskDelay(pdMS_TO_TICKS(300));
+    }
+}