penguin/homekey-relay

homekey-relay

commit 0c4e0bd17fbf39ee8ca9dccd328f523f055cb4fb

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-04T01:58:13Z
subjectConnects PN532 and ESP32-C3
commit 0c4e0bd17fbf39ee8ca9dccd328f523f055cb4fb
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-10-04T01:58:13Z

    Connects PN532 and ESP32-C3
---
 README.md                                  |   7 +
 custom_components/homekey_reader/sensor.py |  16 +-
 esp32/main/CMakeLists.txt                  |   4 +-
 esp32/main/main.c                          | 240 ++++++++++++-----------------
 esp32/main/pnc32.c                         | 233 ++++++++++++++++++++++++++++
 esp32/main/reader.h                        |  88 +++++++++++
 6 files changed, 435 insertions(+), 153 deletions(-)

diff --git a/README.md b/README.md
index 220eccc..15bb643 100644
--- a/README.md
+++ b/README.md
@@ -67,3 +67,10 @@ byte 1      message type = 1 (mock PN532 tag)
 byte 2..3   sequence, uint16 little-endian
 byte 4..7   mock UID = DE AD BE EF
 ```
+
+### Testing the PN532
+
+```
+LIBNFC_DEVICE=pn532_uart:/dev/cu.usbserial-8310 nfc-list
+LIBNFC_DEVICE=pn532_uart:/dev/cu.usbserial-8310 nfc-poll
+```
diff --git a/custom_components/homekey_reader/sensor.py b/custom_components/homekey_reader/sensor.py
index 12543ee..221caaa 100644
--- a/custom_components/homekey_reader/sensor.py
+++ b/custom_components/homekey_reader/sensor.py
@@ -65,28 +65,27 @@ class HomeKeyReaderUidSensor(SensorEntity):
             if payload is None:
                 return
 
-            if len(payload) < 8:
+            if len(payload) not in (7, 10, 13):
                 _LOGGER.warning(
-                    "Short HomeKey Reader packet from %s: %s",
+                    "Invalid HomeKey Reader packet from %s: %s",
                     service_info.address,
                     payload.hex(),
                 )
                 return
 
             version = payload[0]
-            msg_type = payload[1]
-            seq = int.from_bytes(payload[2:4], "little")
-            data = payload[4:]
+            seq = int.from_bytes(payload[1:3], "little")
+            data = payload[3:]
 
-            if version != 1 or msg_type != 1 or len(data) < 4:
+            if version != 1:
                 _LOGGER.warning(
-                    "Unsupported HomeKey Reader packet from %s: %s",
+                    "Unsupported HomeKey Reader version from %s: %s",
                     service_info.address,
                     payload.hex(),
                 )
                 return
 
-            uid = ":".join(f"{byte:02X}" for byte in data[:4])
+            uid = ":".join(f"{byte:02X}" for byte in data)
 
             self._attr_native_value = uid
             self._attr_available = True
@@ -95,7 +94,6 @@ class HomeKeyReaderUidSensor(SensorEntity):
                 "rssi": service_info.rssi,
                 "sequence": seq,
                 "protocol_version": version,
-                "message_type": msg_type,
                 "raw": payload.hex(),
             }
             self.async_write_ha_state()
diff --git a/esp32/main/CMakeLists.txt b/esp32/main/CMakeLists.txt
index f6903aa..661faba 100644
--- a/esp32/main/CMakeLists.txt
+++ b/esp32/main/CMakeLists.txt
@@ -1,5 +1,5 @@
 idf_component_register(
-    SRCS "main.c"
+    SRCS "main.c" "pnc32.c"
     INCLUDE_DIRS "."
-    REQUIRES bt nvs_flash
+    PRIV_REQUIRES bt nvs_flash driver esp_timer esp_hw_support esp_driver_uart
 )
diff --git a/esp32/main/main.c b/esp32/main/main.c
index c5d1103..2f6ba1c 100644
--- a/esp32/main/main.c
+++ b/esp32/main/main.c
@@ -1,208 +1,164 @@
 #include <assert.h>
-#include <stdint.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_hs.h"
 #include "host/util/util.h"
 #include "services/gap/ble_svc_gap.h"
 
-#define DEVICE_NAME "homekey-reader"
-#define ADV_DURATION_MS 2000
+#include "reader.h"
 
-/*
- * Canonical UUID:
- *   7b1e0001-7b1e-4a11-9abc-0123456789ab
- *
- * BLE advertising puts 128-bit UUIDs on the wire least-significant byte first.
- */
-static const uint8_t service_uuid_le[16] = {
-    0xab, 0x89, 0x67, 0x45,
-    0x23, 0x01, 0xbc, 0x9a,
-    0x11, 0x4a, 0x1e, 0x7b,
-    0x01, 0x00, 0x1e, 0x7b,
-};
+#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. */
 static uint8_t own_addr_type;
-static uint16_t mock_seq;
+static uint16_t event_seq;
+static bool ble_ready;
+static bool advertising;
+static bool have_pending;
+static nfc_card_t pending;
 
-static void start_advertising(void);
+static void advertise_next(void);
 
 static int gap_event(struct ble_gap_event *event, void *arg)
 {
-    switch (event->type) {
-    case BLE_GAP_EVENT_CONNECT:
-        if (event->connect.status == 0) {
-            ESP_LOGI(TAG, "BLE central connected, handle=%u",
-                     event->connect.conn_handle);
-        } else {
-            ESP_LOGW(TAG, "BLE connection failed, status=%d",
-                     event->connect.status);
-            start_advertising();
-        }
-        return 0;
-
-    case BLE_GAP_EVENT_DISCONNECT:
-        ESP_LOGI(TAG, "BLE central disconnected, reason=%d",
-                 event->disconnect.reason);
-        start_advertising();
-        return 0;
-
-    case BLE_GAP_EVENT_ADV_COMPLETE:
-        /*
-         * Change the sequence number so Home Assistant sees changing
-         * service data instead of deduplicating identical advertisements.
-         */
-        mock_seq++;
-        start_advertising();
-        return 0;
-
-    default:
-        return 0;
+    (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;
 }
 
-static void build_adv_data(uint8_t adv[29])
-{
-    size_t p = 0;
-
-    /* Flags AD structure: general discoverable, BR/EDR not supported. */
-    adv[p++] = 0x02;
-    adv[p++] = 0x01;
-    adv[p++] = 0x06;
-
-    /*
-     * 128-bit Service Data AD structure (type 0x21):
-     *
-     *   UUID[16]
-     *   version[1]   = 1
-     *   type[1]      = 1 (mock PN532 tag)
-     *   seq[2]       = little endian
-     *   data[4]      = fake NFCID1 / UID: DE:AD:BE:EF
-     *
-     * AD length is 1 byte type + 16 byte UUID + 8 byte payload = 25.
-     */
-    adv[p++] = 25;
-    adv[p++] = 0x21;
-
-    memcpy(&adv[p], service_uuid_le, sizeof(service_uuid_le));
-    p += sizeof(service_uuid_le);
-
-    adv[p++] = 1; /* protocol version */
-    adv[p++] = 1; /* message type: mock PN532 tag */
-    adv[p++] = (uint8_t)(mock_seq & 0xff);
-    adv[p++] = (uint8_t)(mock_seq >> 8);
-    adv[p++] = 0xde;
-    adv[p++] = 0xad;
-    adv[p++] = 0xbe;
-    adv[p++] = 0xef;
-
-    assert(p == 29);
-}
-
-static void start_advertising(void)
+static void advertise_next(void)
 {
-    uint8_t adv[29];
-    struct ble_gap_adv_params params = {0};
-    int rc;
-
-    build_adv_data(adv);
-
-    rc = ble_gap_adv_set_data(adv, sizeof(adv));
-    if (rc != 0) {
-        ESP_LOGE(TAG, "ble_gap_adv_set_data failed: %d", rc);
+    if (!ble_ready || advertising) {
         return;
     }
-
-    params.conn_mode = BLE_GAP_CONN_MODE_UND;
-    params.disc_mode = BLE_GAP_DISC_MODE_GEN;
-    params.itvl_min = BLE_GAP_ADV_ITVL_MS(250);
-    params.itvl_max = BLE_GAP_ADV_ITVL_MS(300);
-
-    rc = ble_gap_adv_start(
-        own_addr_type,
-        NULL,
-        ADV_DURATION_MS,
-        &params,
-        gap_event,
-        NULL
-    );
-    if (rc != 0) {
-        ESP_LOGE(TAG, "ble_gap_adv_start failed: %d", rc);
+    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;
     }
+}
+
+static void on_card_event(struct ble_npl_event *event)
+{
+    (void)event;
+    advertise_next();
+}
 
-    ESP_LOGI(TAG,
-             "advertising mock PN532 packet: seq=%u uid=DE:AD:BE:EF",
-             mock_seq);
+/* Called from PN532 task: copy the UID, then notify the host task.
+ * No BLE calls or shared advertisement buffers cross task boundaries.
+ */
+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;
 }
 
 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);
 }
 
 static void on_sync(void)
 {
-    int rc;
     uint8_t addr[6];
-
-    rc = ble_hs_util_ensure_addr(0);
+    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",
+    ESP_LOGI(TAG, "BLE address %02X:%02X:%02X:%02X:%02X:%02X",
              addr[5], addr[4], addr[3], addr[2], addr[1], addr[0]);
-
-    start_advertising();
+    ble_ready = true;
+    advertise_next();
 }
 
 static void host_task(void *param)
 {
-    ESP_LOGI(TAG, "NimBLE host started");
+    (void)param;
     nimble_port_run();
     nimble_port_freertos_deinit();
 }
 
 void app_main(void)
 {
-    esp_err_t err;
-    int rc;
-
-    err = nvs_flash_init();
-    if (err == ESP_ERR_NVS_NO_FREE_PAGES ||
-        err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
+    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());
 
-    err = nimble_port_init();
-    if (err != ESP_OK) {
-        ESP_LOGE(TAG, "nimble_port_init failed: %s", esp_err_to_name(err));
-        return;
-    }
-
+    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();
-    rc = ble_svc_gap_device_name_set(DEVICE_NAME);
+    int rc = ble_svc_gap_device_name_set(DEVICE_NAME);
     assert(rc == 0);
-
-    ESP_LOGI(TAG, "starting BLE mock PN532 transmitter");
     nimble_port_freertos_init(host_task);
+
+    /* app_main is already a FreeRTOS task; UART waits yield to NimBLE. */
+    start_polling(report_card);
 }
diff --git a/esp32/main/pnc32.c b/esp32/main/pnc32.c
new file mode 100644
index 0000000..0e220e5
--- /dev/null
+++ b/esp32/main/pnc32.c
@@ -0,0 +1,233 @@
+#include <string.h>
+
+#include "driver/uart.h"
+#include "esp_err.h"
+#include "esp_log.h"
+#include "esp_timer.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/task.h"
+#include "reader.h"
+
+#define PN532_UART UART_NUM_1
+#define PN532_TX_GPIO 4
+#define PN532_RX_GPIO 3
+#define PN532_BAUD 115200
+#define COMMAND_TIMEOUT_MS 1000
+#define POLL_INTERVAL_MS 300
+
+static const char *TAG = "pn532";
+static const uint8_t ack[] = {0x00, 0x00, 0xff, 0x00, 0xff, 0x00};
+
+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));
+    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);
+}
+
+static bool write_bytes(const uint8_t *data, size_t len)
+{
+    ESP_LOG_BUFFER_HEX_LEVEL(TAG, data, len, ESP_LOG_DEBUG);
+    return uart_write_bytes(PN532_UART, data, len) == (int)len &&
+           uart_wait_tx_done(PN532_UART, pdMS_TO_TICKS(100)) == ESP_OK;
+}
+
+/* One deadline for the entire command, including ACK and fragmented response. */
+static bool read_exact(uint8_t *data, size_t len, int64_t deadline)
+{
+    size_t received = 0;
+    while (received < len) {
+        int64_t remaining = deadline - esp_timer_get_time();
+        if (remaining <= 0) {
+            return false;
+        }
+        TickType_t ticks = pdMS_TO_TICKS((remaining + 999) / 1000);
+        if (ticks == 0) {
+            ticks = 1;
+        }
+        int n = uart_read_bytes(PN532_UART, data + received, len - received, ticks);
+        if (n < 0) {
+            return false;
+        }
+        received += (size_t)n;
+    }
+    return true;
+}
+
+/* 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 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)) {
+            return -1;
+        }
+    } while (prefix[0] != 0 || prefix[1] != 0 || prefix[2] != 0xff);
+
+    uint8_t lengths[2];
+    if (!read_exact(lengths, 2, deadline)) {
+        return -1;
+    }
+    if (lengths[0] == 0 && lengths[1] == 0xff) {
+        uint8_t postamble;
+        return read_exact(&postamble, 1, deadline) && postamble == 0 ? 0 : -1;
+    }
+    size_t len = lengths[0];
+    if (len == 0 || (uint8_t)(lengths[0] + lengths[1]) != 0 || len > capacity) {
+        return -1;
+    }
+    if (!read_exact(payload, len, deadline)) {
+        return -1;
+    }
+    uint8_t tail[2];
+    if (!read_exact(tail, 2, deadline) || tail[1] != 0) {
+        return -1;
+    }
+    uint8_t checksum = tail[0];
+    for (size_t i = 0; i < len; ++i) {
+        checksum += payload[i];
+    }
+    return checksum == 0 ? (int)len : -1;
+}
+
+/* Normal frame builder. cmd includes command code, excludes host TFI (D4). */
+static int command(const uint8_t *cmd, size_t cmd_len, uint8_t response[128])
+{
+    uint8_t frame[64];
+    if (cmd_len == 0 || cmd_len > sizeof(frame) - 8) {
+        return -1;
+    }
+    size_t len = cmd_len + 1;
+    frame[0] = 0;
+    frame[1] = 0;
+    frame[2] = 0xff;
+    frame[3] = (uint8_t)len;
+    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];
+    }
+    frame[6 + cmd_len] = (uint8_t)(0 - checksum);
+    frame[7 + cmd_len] = 0;
+
+    if (!write_bytes(frame, cmd_len + 8)) {
+        goto failed;
+    }
+    int64_t deadline = esp_timer_get_time() + COMMAND_TIMEOUT_MS * 1000;
+    int n;
+    do {
+        n = read_frame(response, 128, deadline);
+    } 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);
+        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]);
+    (void)write_bytes(ack, sizeof(ack));
+    vTaskDelay(pdMS_TO_TICKS(20));
+    (void)uart_flush_input(PN532_UART);
+    return -1;
+}
+
+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];
+    if (!write_bytes(wakeup, sizeof(wakeup))) {
+        return false;
+    }
+    vTaskDelay(pdMS_TO_TICKS(10));
+    (void)uart_flush_input(PN532_UART);
+    int n = command(firmware, sizeof(firmware), response);
+    if (n != 6 || response[2] != 0x32) {
+        ESP_LOGE(TAG, "invalid PN532 firmware response");
+        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) {
+        return false;
+    }
+    return true;
+}
+
+/* 1 = card, 0 = confirmed no target, -1 = transport/protocol failure. */
+static int poll_card(nfc_card_t *card)
+{
+    static const uint8_t poll[] = {0x4a, 0x01, 0x00};
+    static const uint8_t rf_off[] = {0x32, 0x01, 0x00};
+    uint8_t response[128];
+    int n = command(poll, sizeof(poll), response);
+    int result = -1;
+    if (n == 3 && response[2] == 0) {
+        result = 0;
+    } else if (n >= 8 && response[2] == 1 &&
+               nfc_uid_valid(response[7]) && n >= 8 + response[7]) {
+        card->uid_len = response[7];
+        memcpy(card->uid, response + 8, card->uid_len);
+        result = 1;
+    } else if (n >= 0) {
+        ESP_LOGW(TAG, "invalid target response");
+    }
+    /* InListPassiveTarget turns RF on again for the next scan.
+     * Turning it off here also lets a held card be reactivated next time.
+     */
+    if (command(rf_off, sizeof(rf_off), response) != 2) {
+        return -1;
+    }
+    return result;
+}
+
+void start_polling(pn532_card_callback_t callback)
+{
+    pn532_uart_init();
+    vTaskDelay(pdMS_TO_TICKS(1000));
+    while (!initialize_reader()) {
+        ESP_LOGE(TAG, "PN532 initialization failed; retrying in 2 seconds");
+        vTaskDelay(pdMS_TO_TICKS(2000));
+    }
+
+    nfc_presence_t presence = {0};
+    for (;;) {
+        nfc_card_t card = {0};
+        int result = poll_card(&card);
+        bool was_present = presence.present;
+        if (nfc_presence_update(&presence, result, &card)) {
+            ESP_LOGI(TAG, "card detected, UID length=%u", card.uid_len);
+            ESP_LOG_BUFFER_HEX_LEVEL(TAG, card.uid, card.uid_len, ESP_LOG_INFO);
+            if (callback(&card)) {
+                nfc_presence_accept(&presence, &card);
+            }
+        }
+        if (was_present && !presence.present) {
+            ESP_LOGI(TAG, "card removed");
+        }
+        vTaskDelay(pdMS_TO_TICKS(POLL_INTERVAL_MS));
+    }
+}
diff --git a/esp32/main/reader.h b/esp32/main/reader.h
new file mode 100644
index 0000000..262e20d
--- /dev/null
+++ b/esp32/main/reader.h
@@ -0,0 +1,88 @@
+#ifndef HOMEKEY_READER_H
+#define HOMEKEY_READER_H
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+#include <string.h>
+
+#define NFC_UID_MAX 10
+
+typedef struct {
+    uint8_t uid[NFC_UID_MAX];
+    uint8_t uid_len;
+} nfc_card_t;
+
+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. */
+void start_polling(pn532_card_callback_t callback);
+
+static inline bool nfc_uid_valid(size_t length)
+{
+    return length == 4 || length == 7 || length == 10;
+}
+
+static inline bool nfc_card_equal(const nfc_card_t *a, const nfc_card_t *b)
+{
+    return a->uid_len == b->uid_len &&
+           memcmp(a->uid, b->uid, a->uid_len) == 0;
+}
+
+typedef struct {
+    nfc_card_t last;
+    bool present;
+    unsigned misses;
+} nfc_presence_t;
+
+/* Only successful no-target scans count toward removal. */
+static inline bool nfc_presence_update(nfc_presence_t *state, int result,
+                                       const nfc_card_t *card)
+{
+    if (result == 1) {
+        state->misses = 0;
+        return !state->present || !nfc_card_equal(&state->last, card);
+    }
+    if (result == 0) {
+        if (state->present && ++state->misses >= 2) {
+            state->present = false;
+        }
+    } else {
+        state->misses = 0;
+    }
+    return false;
+}
+
+static inline void nfc_presence_accept(nfc_presence_t *state,
+                                       const nfc_card_t *card)
+{
+    state->last = *card;
+    state->present = true;
+}
+
+/* Nonconnectable legacy ADV: one 128-bit service-data AD, at most 31 bytes.
+ * v2 payload = version(2), event_seq LE16, UID (length inferred).
+ * Flags/name/type are omitted to fit a full 10-byte UID.
+ */
+static inline size_t build_card_adv(uint8_t adv[31], const nfc_card_t *card,
+                                    uint16_t event_seq)
+{
+    static const uint8_t uuid[16] = {
+        0xab, 0x89, 0x67, 0x45, 0x23, 0x01, 0xbc, 0x9a,
+        0x11, 0x4a, 0x1e, 0x7b, 0x01, 0x00, 0x1e, 0x7b,
+    };
+    if (!nfc_uid_valid(card->uid_len)) {
+        return 0;
+    }
+    adv[0] = (uint8_t)(20 + card->uid_len);
+    adv[1] = 0x21;
+    memcpy(adv + 2, uuid, sizeof(uuid));
+	// Protocol Version
+    adv[18] = 1;
+    adv[19] = (uint8_t)event_seq;
+    adv[20] = (uint8_t)(event_seq >> 8);
+    memcpy(adv + 21, card->uid, card->uid_len);
+    return 21 + card->uid_len;
+}
+
+#endif