commit 0c4e0bd17fbf39ee8ca9dccd328f523f055cb4fb
| author | 斟酌 鵬兄 <tgckpg@gmail.com> |
| date | 2026-10-04T01:58:13Z |
| subject | Connects 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,
- ¶ms,
- 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,
+ ¶ms, 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