penguin/homekey-relay

homekey-relay

commit 5be8ff6a1040884ca1ee504a28968f79fa9760ba

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-05T02:28:58Z
subjectAdded service for key exchange
commit 5be8ff6a1040884ca1ee504a28968f79fa9760ba
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-10-05T02:28:58Z

    Added service for key exchange
---
 README.md                                          |  40 +-
 custom_components/homekey_reader/__init__.py       |  10 +-
 custom_components/homekey_reader/config_flow.py    | 164 ++---
 custom_components/homekey_reader/manifest.json     |   8 +-
 custom_components/homekey_reader/sensor.py         | 220 +++----
 custom_components/homekey_reader/strings.json      |  34 +-
 .../homekey_reader/translations/en.json            |  34 +-
 esp32/PROTOCOL.md                                  |  52 ++
 esp32/main/CMakeLists.txt                          |  34 +-
 esp32/main/main.c                                  | 127 +++-
 esp32/main/pin_test.c                              | 102 +--
 esp32/main/pn532.c                                 | 455 +++++++-------
 esp32/main/pn532_test.c                            | 690 ++++++++++-----------
 esp32/main/reader.h                                |  85 +--
 esp32/main/relay.c                                 | 211 +++++++
 esp32/main/relay.h                                 |  15 +
 tests/include/driver/uart.h                        |   1 +
 tests/include/esp_err.h                            |   1 +
 tests/include/esp_log.h                            |   1 +
 tests/include/esp_random.h                         |   1 +
 tests/include/esp_timer.h                          |   1 +
 tests/include/freertos/FreeRTOS.h                  |   3 +
 tests/include/freertos/queue.h                     |   2 +
 tests/include/freertos/semphr.h                    |   5 +
 tests/include/freertos/task.h                      |   1 +
 tests/include/mock.h                               |  47 ++
 tests/pn532_test.c                                 | 175 ++++++
 tests/relay_test.c                                 | 123 ++++
 tests/run.sh                                       |   9 +
 29 files changed, 1705 insertions(+), 946 deletions(-)

diff --git a/README.md b/README.md
index e9cd928..57e51a3 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,12 @@
 
 In development. NOT FOR USE!
 
-## Home Assistant UI setup
+Current firmware connects directly to `homekey-go` over BLE GATT and relays
+NFC APDUs. The HACS component and service-data examples below describe the
+earlier advertisement-based firmware; current firmware does not emit those
+UID advertisements. See [the current relay protocol](esp32/PROTOCOL.md).
+
+## Legacy Home Assistant UI setup
 
 HACS
 
@@ -36,21 +41,9 @@ idf.py set-target esp32c3
 idf.py -p /dev/cu.usbmodem101 flash monitor
 ```
 
-## Mock Flow
+## Current flow
 
 ```text
-ESP32-C3
-  mock PN532 UID (DE:AD:BE:EF)
-       |
-       v
-BLE service-data advertisement
-       |
-       v
-Home Assistant Bluetooth
-       |
-       +--> HomeKey Reader config entry
-       +--> sensor: Last NFC UID
-       +--> event: homekey_reader_packet
 ```
 
 Service UUID:
@@ -62,8 +55,8 @@ Service UUID:
 Advertisement payload after the UUID:
 
 ```text
-byte 0      protocol version = 1
-byte 1      message type = 1 (mock PN532 tag)
+byte 0	  protocol version = 1
+byte 1	  message type = 1 (mock PN532 tag)
 byte 2..3   sequence, uint16 little-endian
 byte 4..7   mock UID = DE AD BE EF
 ```
@@ -80,7 +73,18 @@ Test pins
 idf.py -B build-pin-test -D PIN_TEST=ON build flash monitor
 ```
 
-Test pnc532
+Test pn532
 ```
-idf.py -B build-pnc532-test -D PNC532_TEST=ON build flash monitor
+idf.py -B build-pn532-test -D PNC532_TEST=ON build flash monitor
 ```
+
+## NFC APDU relay
+
+The firmware now exposes session status and an APDU mailbox for `homekey-go`
+0.0.3. Flash the matching firmware before updating the Go service. NFC runs in
+one UART owner task; the RF field stays on throughout authentication.
+
+The first test uses an existing Home Key opened manually in Wallet. Express-mode
+ECP wakeup, FAST authentication and unknown-device attestation are separate
+follow-up work. `homekey-go` verifies the key; UID detection alone never unlocks.
+See `esp32/PROTOCOL.md` for the mailbox format and `tests/run.sh` for host tests.
diff --git a/custom_components/homekey_reader/__init__.py b/custom_components/homekey_reader/__init__.py
index a15f2be..df6a7d7 100644
--- a/custom_components/homekey_reader/__init__.py
+++ b/custom_components/homekey_reader/__init__.py
@@ -10,11 +10,11 @@ PLATFORMS = [Platform.SENSOR]
 
 
 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
-    """Set up HomeKey Reader from a config entry."""
-    await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
-    return True
+	"""Set up HomeKey Reader from a config entry."""
+	await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+	return True
 
 
 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
-    """Unload a HomeKey Reader config entry."""
-    return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+	"""Unload a HomeKey Reader config entry."""
+	return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/custom_components/homekey_reader/config_flow.py b/custom_components/homekey_reader/config_flow.py
index a28b511..2191e5a 100644
--- a/custom_components/homekey_reader/config_flow.py
+++ b/custom_components/homekey_reader/config_flow.py
@@ -15,85 +15,85 @@ from .const import DOMAIN, SERVICE_UUID
 
 
 class HomeKeyReaderConfigFlow(ConfigFlow, domain=DOMAIN):
-    """Handle a config flow for HomeKey Reader."""
-
-    VERSION = 1
-
-    def __init__(self) -> None:
-        self._discovery_info: BluetoothServiceInfoBleak | None = None
-        self._discovered: dict[str, BluetoothServiceInfoBleak] = {}
-
-    async def async_step_bluetooth(
-        self, discovery_info: BluetoothServiceInfoBleak
-    ) -> ConfigFlowResult:
-        """Handle Bluetooth discovery."""
-        await self.async_set_unique_id(discovery_info.address)
-        self._abort_if_unique_id_configured()
-
-        self._discovery_info = discovery_info
-        name = discovery_info.name or discovery_info.address
-        self.context["title_placeholders"] = {"name": name}
-        return await self.async_step_bluetooth_confirm()
-
-    async def async_step_bluetooth_confirm(
-        self, user_input: dict[str, Any] | None = None
-    ) -> ConfigFlowResult:
-        """Confirm a Bluetooth-discovered reader."""
-        assert self._discovery_info is not None
-
-        if user_input is not None:
-            return self.async_create_entry(
-                title=self._discovery_info.name or "HomeKey Reader",
-                data={CONF_ADDRESS: self._discovery_info.address},
-            )
-
-        self._set_confirm_only()
-        return self.async_show_form(
-            step_id="bluetooth_confirm",
-            description_placeholders={
-                "name": self._discovery_info.name or self._discovery_info.address,
-                "address": self._discovery_info.address,
-            },
-        )
-
-    async def async_step_user(
-        self, user_input: dict[str, Any] | None = None
-    ) -> ConfigFlowResult:
-        """Let the user choose a currently visible reader."""
-        if user_input is not None:
-            address = user_input[CONF_ADDRESS]
-            info = self._discovered[address]
-
-            await self.async_set_unique_id(address, raise_on_progress=False)
-            self._abort_if_unique_id_configured()
-
-            return self.async_create_entry(
-                title=info.name or "HomeKey Reader",
-                data={CONF_ADDRESS: address},
-            )
-
-        configured = self._async_current_ids(include_ignore=False)
-        wanted_uuid = SERVICE_UUID.lower()
-
-        for info in bluetooth.async_discovered_service_info(
-            self.hass, connectable=False
-        ):
-            service_data_uuids = {uuid.lower() for uuid in info.service_data}
-            if wanted_uuid not in service_data_uuids:
-                continue
-            if info.address in configured:
-                continue
-            self._discovered[info.address] = info
-
-        if not self._discovered:
-            return self.async_abort(reason="no_devices_found")
-
-        choices = {
-            address: f"{info.name or 'HomeKey Reader'} ({address})"
-            for address, info in self._discovered.items()
-        }
-
-        return self.async_show_form(
-            step_id="user",
-            data_schema=vol.Schema({vol.Required(CONF_ADDRESS): vol.In(choices)}),
-        )
+	"""Handle a config flow for HomeKey Reader."""
+
+	VERSION = 1
+
+	def __init__(self) -> None:
+		self._discovery_info: BluetoothServiceInfoBleak | None = None
+		self._discovered: dict[str, BluetoothServiceInfoBleak] = {}
+
+	async def async_step_bluetooth(
+		self, discovery_info: BluetoothServiceInfoBleak
+	) -> ConfigFlowResult:
+		"""Handle Bluetooth discovery."""
+		await self.async_set_unique_id(discovery_info.address)
+		self._abort_if_unique_id_configured()
+
+		self._discovery_info = discovery_info
+		name = discovery_info.name or discovery_info.address
+		self.context["title_placeholders"] = {"name": name}
+		return await self.async_step_bluetooth_confirm()
+
+	async def async_step_bluetooth_confirm(
+		self, user_input: dict[str, Any] | None = None
+	) -> ConfigFlowResult:
+		"""Confirm a Bluetooth-discovered reader."""
+		assert self._discovery_info is not None
+
+		if user_input is not None:
+			return self.async_create_entry(
+				title=self._discovery_info.name or "HomeKey Reader",
+				data={CONF_ADDRESS: self._discovery_info.address},
+			)
+
+		self._set_confirm_only()
+		return self.async_show_form(
+			step_id="bluetooth_confirm",
+			description_placeholders={
+				"name": self._discovery_info.name or self._discovery_info.address,
+				"address": self._discovery_info.address,
+			},
+		)
+
+	async def async_step_user(
+		self, user_input: dict[str, Any] | None = None
+	) -> ConfigFlowResult:
+		"""Let the user choose a currently visible reader."""
+		if user_input is not None:
+			address = user_input[CONF_ADDRESS]
+			info = self._discovered[address]
+
+			await self.async_set_unique_id(address, raise_on_progress=False)
+			self._abort_if_unique_id_configured()
+
+			return self.async_create_entry(
+				title=info.name or "HomeKey Reader",
+				data={CONF_ADDRESS: address},
+			)
+
+		configured = self._async_current_ids(include_ignore=False)
+		wanted_uuid = SERVICE_UUID.lower()
+
+		for info in bluetooth.async_discovered_service_info(
+			self.hass, connectable=False
+		):
+			service_data_uuids = {uuid.lower() for uuid in info.service_data}
+			if wanted_uuid not in service_data_uuids:
+				continue
+			if info.address in configured:
+				continue
+			self._discovered[info.address] = info
+
+		if not self._discovered:
+			return self.async_abort(reason="no_devices_found")
+
+		choices = {
+			address: f"{info.name or 'HomeKey Reader'} ({address})"
+			for address, info in self._discovered.items()
+		}
+
+		return self.async_show_form(
+			step_id="user",
+			data_schema=vol.Schema({vol.Required(CONF_ADDRESS): vol.In(choices)}),
+		)
diff --git a/custom_components/homekey_reader/manifest.json b/custom_components/homekey_reader/manifest.json
index bc56041..176b699 100644
--- a/custom_components/homekey_reader/manifest.json
+++ b/custom_components/homekey_reader/manifest.json
@@ -5,10 +5,10 @@
   "config_flow": true,
   "dependencies": ["bluetooth_adapters"],
   "bluetooth": [
-    {
-      "service_data_uuid": "7b1e0001-7b1e-4a11-9abc-0123456789ab",
-      "connectable": false
-    }
+	{
+	  "service_data_uuid": "7b1e0001-7b1e-4a11-9abc-0123456789ab",
+	  "connectable": false
+	}
   ],
   "codeowners": [],
   "integration_type": "device",
diff --git a/custom_components/homekey_reader/sensor.py b/custom_components/homekey_reader/sensor.py
index 221caaa..885c08e 100644
--- a/custom_components/homekey_reader/sensor.py
+++ b/custom_components/homekey_reader/sensor.py
@@ -18,117 +18,117 @@ _LOGGER = logging.getLogger(__name__)
 
 
 async def async_setup_entry(
-    hass: HomeAssistant,
-    entry: ConfigEntry,
-    async_add_entities: AddEntitiesCallback,
+	hass: HomeAssistant,
+	entry: ConfigEntry,
+	async_add_entities: AddEntitiesCallback,
 ) -> None:
-    """Set up the HomeKey Reader sensor."""
-    async_add_entities([HomeKeyReaderUidSensor(hass, entry)])
+	"""Set up the HomeKey Reader sensor."""
+	async_add_entities([HomeKeyReaderUidSensor(hass, entry)])
 
 
 class HomeKeyReaderUidSensor(SensorEntity):
-    """Last NFC UID received from one HomeKey Reader."""
-
-    _attr_has_entity_name = True
-    _attr_name = "Last NFC UID"
-    _attr_icon = "mdi:nfc"
-
-    def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
-        self.hass = hass
-        self._entry = entry
-        self._address = entry.data[CONF_ADDRESS]
-        self._attr_unique_id = f"{self._address}_last_uid"
-        self._attr_native_value = None
-        self._attr_available = False
-        self._attrs: dict[str, object] = {}
-
-        self._attr_device_info = DeviceInfo(
-            identifiers={(DOMAIN, self._address)},
-            name=entry.title,
-            manufacturer="HomeKey Reader",
-            model="ESP32-C3 + PN532",
-        )
-
-    @property
-    def extra_state_attributes(self) -> dict[str, object]:
-        return self._attrs
-
-    async def async_added_to_hass(self) -> None:
-        """Subscribe to Bluetooth advertisements."""
-
-        @callback
-        def _packet_seen(
-            service_info: bluetooth.BluetoothServiceInfoBleak,
-            change: bluetooth.BluetoothChange,
-        ) -> None:
-            payload = service_info.service_data.get(SERVICE_UUID)
-            if payload is None:
-                return
-
-            if len(payload) not in (7, 10, 13):
-                _LOGGER.warning(
-                    "Invalid HomeKey Reader packet from %s: %s",
-                    service_info.address,
-                    payload.hex(),
-                )
-                return
-
-            version = payload[0]
-            seq = int.from_bytes(payload[1:3], "little")
-            data = payload[3:]
-
-            if version != 1:
-                _LOGGER.warning(
-                    "Unsupported HomeKey Reader version from %s: %s",
-                    service_info.address,
-                    payload.hex(),
-                )
-                return
-
-            uid = ":".join(f"{byte:02X}" for byte in data)
-
-            self._attr_native_value = uid
-            self._attr_available = True
-            self._attrs = {
-                "address": service_info.address,
-                "rssi": service_info.rssi,
-                "sequence": seq,
-                "protocol_version": version,
-                "raw": payload.hex(),
-            }
-            self.async_write_ha_state()
-
-            self.hass.bus.async_fire(
-                EVENT_PACKET,
-                {
-                    "uid": uid,
-                    **self._attrs,
-                },
-            )
-
-        @callback
-        def _unavailable(_service_info: bluetooth.BluetoothServiceInfoBleak) -> None:
-            self._attr_available = False
-            self.async_write_ha_state()
-
-        self.async_on_remove(
-            bluetooth.async_register_callback(
-                self.hass,
-                _packet_seen,
-                {
-                    "address": self._address,
-                    "service_data_uuid": SERVICE_UUID,
-                    "connectable": False,
-                },
-                bluetooth.BluetoothScanningMode.ACTIVE,
-                replay=bluetooth.BluetoothCallbackReplay.NEWEST_FIRST,
-            )
-        )
-        self.async_on_remove(
-            bluetooth.async_track_unavailable(
-                self.hass,
-                _unavailable,
-                self._address,
-                connectable=False,
-            )
-        )
+	"""Last NFC UID received from one HomeKey Reader."""
+
+	_attr_has_entity_name = True
+	_attr_name = "Last NFC UID"
+	_attr_icon = "mdi:nfc"
+
+	def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
+		self.hass = hass
+		self._entry = entry
+		self._address = entry.data[CONF_ADDRESS]
+		self._attr_unique_id = f"{self._address}_last_uid"
+		self._attr_native_value = None
+		self._attr_available = False
+		self._attrs: dict[str, object] = {}
+
+		self._attr_device_info = DeviceInfo(
+			identifiers={(DOMAIN, self._address)},
+			name=entry.title,
+			manufacturer="HomeKey Reader",
+			model="ESP32-C3 + PN532",
+		)
+
+	@property
+	def extra_state_attributes(self) -> dict[str, object]:
+		return self._attrs
+
+	async def async_added_to_hass(self) -> None:
+		"""Subscribe to Bluetooth advertisements."""
+
+		@callback
+		def _packet_seen(
+			service_info: bluetooth.BluetoothServiceInfoBleak,
+			change: bluetooth.BluetoothChange,
+		) -> None:
+			payload = service_info.service_data.get(SERVICE_UUID)
+			if payload is None:
+				return
+
+			if len(payload) not in (7, 10, 13):
+				_LOGGER.warning(
+					"Invalid HomeKey Reader packet from %s: %s",
+					service_info.address,
+					payload.hex(),
+				)
+				return
+
+			version = payload[0]
+			seq = int.from_bytes(payload[1:3], "little")
+			data = payload[3:]
+
+			if version != 1:
+				_LOGGER.warning(
+					"Unsupported HomeKey Reader version from %s: %s",
+					service_info.address,
+					payload.hex(),
+				)
+				return
+
+			uid = ":".join(f"{byte:02X}" for byte in data)
+
+			self._attr_native_value = uid
+			self._attr_available = True
+			self._attrs = {
+				"address": service_info.address,
+				"rssi": service_info.rssi,
+				"sequence": seq,
+				"protocol_version": version,
+				"raw": payload.hex(),
+			}
+			self.async_write_ha_state()
+
+			self.hass.bus.async_fire(
+				EVENT_PACKET,
+				{
+					"uid": uid,
+					**self._attrs,
+				},
+			)
+
+		@callback
+		def _unavailable(_service_info: bluetooth.BluetoothServiceInfoBleak) -> None:
+			self._attr_available = False
+			self.async_write_ha_state()
+
+		self.async_on_remove(
+			bluetooth.async_register_callback(
+				self.hass,
+				_packet_seen,
+				{
+					"address": self._address,
+					"service_data_uuid": SERVICE_UUID,
+					"connectable": False,
+				},
+				bluetooth.BluetoothScanningMode.ACTIVE,
+				replay=bluetooth.BluetoothCallbackReplay.NEWEST_FIRST,
+			)
+		)
+		self.async_on_remove(
+			bluetooth.async_track_unavailable(
+				self.hass,
+				_unavailable,
+				self._address,
+				connectable=False,
+			)
+		)
diff --git a/custom_components/homekey_reader/strings.json b/custom_components/homekey_reader/strings.json
index b3f286e..a0c17a9 100644
--- a/custom_components/homekey_reader/strings.json
+++ b/custom_components/homekey_reader/strings.json
@@ -1,22 +1,22 @@
 {
   "title": "HomeKey Reader",
   "config": {
-    "step": {
-      "user": {
-        "title": "Select a HomeKey Reader",
-        "description": "Choose a currently visible reader.",
-        "data": {
-          "address": "Reader"
-        }
-      },
-      "bluetooth_confirm": {
-        "title": "Set up {name}",
-        "description": "Set up HomeKey Reader {name} at {address}?"
-      }
-    },
-    "abort": {
-      "already_configured": "This reader is already configured.",
-      "no_devices_found": "No HomeKey Reader is currently visible over Bluetooth."
-    }
+	"step": {
+	  "user": {
+		"title": "Select a HomeKey Reader",
+		"description": "Choose a currently visible reader.",
+		"data": {
+		  "address": "Reader"
+		}
+	  },
+	  "bluetooth_confirm": {
+		"title": "Set up {name}",
+		"description": "Set up HomeKey Reader {name} at {address}?"
+	  }
+	},
+	"abort": {
+	  "already_configured": "This reader is already configured.",
+	  "no_devices_found": "No HomeKey Reader is currently visible over Bluetooth."
+	}
   }
 }
diff --git a/custom_components/homekey_reader/translations/en.json b/custom_components/homekey_reader/translations/en.json
index b3f286e..a0c17a9 100644
--- a/custom_components/homekey_reader/translations/en.json
+++ b/custom_components/homekey_reader/translations/en.json
@@ -1,22 +1,22 @@
 {
   "title": "HomeKey Reader",
   "config": {
-    "step": {
-      "user": {
-        "title": "Select a HomeKey Reader",
-        "description": "Choose a currently visible reader.",
-        "data": {
-          "address": "Reader"
-        }
-      },
-      "bluetooth_confirm": {
-        "title": "Set up {name}",
-        "description": "Set up HomeKey Reader {name} at {address}?"
-      }
-    },
-    "abort": {
-      "already_configured": "This reader is already configured.",
-      "no_devices_found": "No HomeKey Reader is currently visible over Bluetooth."
-    }
+	"step": {
+	  "user": {
+		"title": "Select a HomeKey Reader",
+		"description": "Choose a currently visible reader.",
+		"data": {
+		  "address": "Reader"
+		}
+	  },
+	  "bluetooth_confirm": {
+		"title": "Set up {name}",
+		"description": "Set up HomeKey Reader {name} at {address}?"
+	  }
+	},
+	"abort": {
+	  "already_configured": "This reader is already configured.",
+	  "no_devices_found": "No HomeKey Reader is currently visible over Bluetooth."
+	}
   }
 }
diff --git a/esp32/PROTOCOL.md b/esp32/PROTOCOL.md
new file mode 100644
index 0000000..70c7f42
--- /dev/null
+++ b/esp32/PROTOCOL.md
@@ -0,0 +1,52 @@
+# BLE NFC relay v1
+
+Service: `7a6b0001-5b21-4f36-8e5d-9c3a26d74210`.
+
+| Characteristic suffix | Access | Value |
+| --- | --- | --- |
+| `0002` | Read/write | Existing 8-byte PING/PONG probe |
+| `0003` | Read | 18-byte NFC session status |
+| `0004` | Read/write | Fragmented APDU request / stable response mailbox |
+
+All multi-byte integers below are big-endian. The UUID suffix means the last
+four digits of the UUID's first component, with the remainder unchanged.
+
+Status: `version:u8=1, active:u8, uid_length:u8, sak:u8, session:u32, uid[10]`.
+UID bytes are metadata, not credentials. Active means an APDU session is open;
+it does not guarantee that the phone remains physically present. Session IDs
+start from a random boot seed and increase for each offered target. Reconnect
+invalidates all old requests and responses.
+
+APDU packet header (12 bytes):
+`version:u8=1, kind:u8, session:u32, sequence:u16, offset:u16, total:u16`.
+
+| Kind | Direction | Meaning |
+| --- | --- | --- |
+| `01` | Go → ESP32 | Command fragment; payload follows the header |
+| `02` | Go → ESP32 | Release session; zero offset/total, no payload |
+| `10` | ESP32 → Go | Pending response; zero offset/total, no payload |
+| `11` | ESP32 → Go | Ready response; zero offset, total = payload size |
+| `12` | ESP32 → Go | Transport error; zero offset, total = 1, error byte |
+
+Each APDU sequence starts at 1 and strictly increases within its session. Only
+one APDU is admitted at a time. Fragments must be contiguous with matching
+session/sequence/total, within 4..240 bytes. A duplicate, gap, stale session or
+busy write is rejected. No APDU is executed until its complete payload is
+queued to the NFC owner task.
+
+Go uses ATT write requests and fragments to MTU-3 when BlueZ reports MTU,
+otherwise to 20 bytes. Responses are read using BlueZ's long-read support.
+The response remains stable across repeated reads until the next complete
+request or a connection/session reset. A GATT callback never touches UART.
+
+Error bytes: 1 = invalid APDU/target, 2 = UART/frame/timeout failure,
+3 = PN532 error or unsupported chaining flags, 4 = response too large.
+Normal `11` responses include the APDU's SW1/SW2 bytes. PN532 errors cannot
+be mistaken for an APDU status word.
+
+The owner holds the selected target while Go sends SELECT/AUTH0/AUTH1 and
+CONTROL FLOW. It turns RF off after release, transport failure, disconnect or
+15-second expiry, then resumes presence polling. Held cards are suppressed by
+the existing UID presence state until removal; disconnected detections are
+retried after reconnection. GPIO3 TX / GPIO4 RX and startup wake packets are
+preserved.
diff --git a/esp32/main/CMakeLists.txt b/esp32/main/CMakeLists.txt
index 5ba8686..e869fb3 100644
--- a/esp32/main/CMakeLists.txt
+++ b/esp32/main/CMakeLists.txt
@@ -2,23 +2,23 @@ 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
-    )
+	idf_component_register(
+		SRCS "pin_test.c"
+		INCLUDE_DIRS "."
+		PRIV_REQUIRES driver esp_driver_uart
+	)
 elseif(PNC532_TEST)
-    idf_component_register(
-        SRCS "pn532_test.c"
-        INCLUDE_DIRS "."
-        PRIV_REQUIRES nvs_flash driver esp_timer
-                      esp_hw_support esp_driver_uart
-    )
+	idf_component_register(
+		SRCS "pn532_test.c"
+		INCLUDE_DIRS "."
+		PRIV_REQUIRES nvs_flash driver esp_timer
+					  esp_hw_support esp_driver_uart
+	)
 else()
-    idf_component_register(
-        SRCS "main.c" "pn532.c"
-        INCLUDE_DIRS "."
-        PRIV_REQUIRES bt nvs_flash driver esp_timer
-                      esp_hw_support esp_driver_uart
-    )
+	idf_component_register(
+		SRCS "main.c" "pn532.c" "relay.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 da97b8c..ccb3caf 100644
--- a/esp32/main/main.c
+++ b/esp32/main/main.c
@@ -12,6 +12,9 @@
 #include "services/gap/ble_svc_gap.h"
 #include "services/gatt/ble_svc_gatt.h"
 #include "reader.h"
+#include "relay.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/task.h"
 
 #define DEVICE_NAME "homekey-reader"
 static const char *TAG = "homekey-reader";
@@ -20,19 +23,55 @@ static const char *TAG = "homekey-reader";
  * 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);
+	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);
+	0x10, 0x42, 0xd7, 0x26, 0x3a, 0x9c, 0x5d, 0x8e, 0x36, 0x4f, 0x21, 0x5b, 0x02, 0x00, 0x6b, 0x7a);
+static const ble_uuid128_t status_uuid = BLE_UUID128_INIT(
+	0x10, 0x42, 0xd7, 0x26, 0x3a, 0x9c, 0x5d, 0x8e, 0x36, 0x4f, 0x21, 0x5b, 0x03, 0x00, 0x6b, 0x7a);
+static const ble_uuid128_t apdu_uuid = BLE_UUID128_INIT(
+	0x10, 0x42, 0xd7, 0x26, 0x3a, 0x9c, 0x5d, 0x8e, 0x36, 0x4f, 0x21, 0x5b, 0x04, 0x00, 0x6b, 0x7a);
+static int status_access(uint16_t conn, uint16_t attr, struct ble_gatt_access_ctxt *ctxt, void *arg)
+{
+	(void)conn;
+	(void)attr;
+	(void)arg;
+	if (ctxt->op != BLE_GATT_ACCESS_OP_READ_CHR)
+		return BLE_ATT_ERR_UNLIKELY;
+	uint8_t data[18];
+	size_t n = relay_status(data);
+	return os_mbuf_append(ctxt->om, data, n) == 0 ? 0 : BLE_ATT_ERR_INSUFFICIENT_RES;
+}
+static int apdu_access(uint16_t conn, uint16_t attr, struct ble_gatt_access_ctxt *ctxt, void *arg)
+{
+	(void)conn;
+	(void)attr;
+	(void)arg;
+	uint8_t data[RELAY_VALUE_MAX];
+	if (ctxt->op == BLE_GATT_ACCESS_OP_READ_CHR) {
+		size_t n = relay_response(data);
+		return os_mbuf_append(ctxt->om, data, n) == 0 ? 0 : BLE_ATT_ERR_INSUFFICIENT_RES;
+	}
+	if (ctxt->op == BLE_GATT_ACCESS_OP_WRITE_CHR) {
+		size_t n = OS_MBUF_PKTLEN(ctxt->om);
+		if (n > sizeof(data) || n < RELAY_HEADER)
+			return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
+		if (os_mbuf_copydata(ctxt->om, 0, n, data) != 0)
+			return BLE_ATT_ERR_UNLIKELY;
+		return relay_write(data, n) ? 0 : BLE_ATT_ERR_VALUE_NOT_ALLOWED;
+	}
+	return BLE_ATT_ERR_UNLIKELY;
+}
 static uint8_t own_addr_type;
 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 int ping_access(uint16_t conn, uint16_t attr,
-					   struct ble_gatt_access_ctxt *ctxt, void *arg)
+static int ping_access(uint16_t conn, uint16_t attr, struct ble_gatt_access_ctxt *ctxt, void *arg)
 {
-	(void)conn; (void)attr; (void)arg;
+	(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))
@@ -48,21 +87,27 @@ static int ping_access(uint16_t conn, uint16_t attr,
 		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;
+		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 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}
-};
+	{.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},
+			 {.uuid = &status_uuid.u, .access_cb = status_access, .flags = BLE_GATT_CHR_F_READ},
+			 {.uuid = &apdu_uuid.u,
+			  .access_cb = apdu_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)
 {
@@ -71,20 +116,25 @@ static int gap_event(struct ble_gap_event *event, void *arg)
 	case BLE_GAP_EVENT_CONNECT:
 		if (event->connect.status == 0) {
 			connection = event->connect.conn_handle;
+			relay_connected(true);
 			have_reply = false;
 			ESP_LOGI(TAG, "BLE gateway connected");
-		} else advertise();
+		} else
+			advertise();
 		break;
 	case BLE_GAP_EVENT_DISCONNECT:
+		relay_connected(false);
 		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();
+		if (connection == BLE_HS_CONN_HANDLE_NONE)
+			advertise();
+		break;
+	default:
 		break;
-	default: break;
 	}
 	return 0;
 }
@@ -96,33 +146,39 @@ static void advertise(void)
 	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; }
+	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; }
+	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);
+	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);
 }
-/* NFC task stays independent. Card events are logged for now;
- * the old non-connectable UID advertisements are replaced by GATT.
- */
+/* This callback runs in the NFC owner task; it holds the target active
+ * until Go finishes authentication or the relay session expires. */
 static bool report_card(const nfc_card_t *card)
 {
 	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;
+	return relay_card(card);
 }
 static void on_reset(int reason)
 {
+	relay_connected(false);
 	connection = BLE_HS_CONN_HANDLE_NONE;
 	have_reply = false;
 	ESP_LOGE(TAG, "NimBLE reset: %d", reason);
@@ -136,8 +192,8 @@ static void on_sync(void)
 	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]);
+	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 *arg)
@@ -146,8 +202,16 @@ static void host_task(void *arg)
 	nimble_port_run();
 	nimble_port_freertos_deinit();
 }
+static void nfc_task(void *arg)
+{
+	(void)arg;
+	start_polling(report_card);
+	vTaskDelete(NULL);
+}
+
 void app_main(void)
 {
+	relay_init();
 	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());
@@ -166,5 +230,6 @@ void app_main(void)
 	rc = ble_gatts_add_svcs(services);
 	assert(rc == 0);
 	nimble_port_freertos_init(host_task);
-	start_polling(report_card);
+	BaseType_t started = xTaskCreate(nfc_task, "pn532", 8192, NULL, 4, NULL);
+	assert(started == pdPASS);
 }
diff --git a/esp32/main/pin_test.c b/esp32/main/pin_test.c
index 74fe746..db1fb9f 100644
--- a/esp32/main/pin_test.c
+++ b/esp32/main/pin_test.c
@@ -14,65 +14,65 @@ 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,
-    };
+	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));
+	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
-    };
+	const uint8_t tx[] = {
+		0x55, 0xaa, 0x00, 0xff, 0x02, 0xfe, 0xd4, 0x2a
+	};
 
-    for (;;) {
-        uint8_t rx[sizeof(tx)] = {0};
-        size_t received = 0;
+	for (;;) {
+		uint8_t rx[sizeof(tx)] = {0};
+		size_t received = 0;
 
-        ESP_ERROR_CHECK(uart_flush_input(TEST_UART));
+		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);
+		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));
+		/* 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;
-        }
+			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);
-        }
+		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");
-        }
+		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));
-    }
+		vTaskDelay(pdMS_TO_TICKS(2000));
+	}
 }
\ No newline at end of file
diff --git a/esp32/main/pn532.c b/esp32/main/pn532.c
index 82f9c2c..62f0068 100644
--- a/esp32/main/pn532.c
+++ b/esp32/main/pn532.c
@@ -7,6 +7,7 @@
 #include "freertos/FreeRTOS.h"
 #include "freertos/task.h"
 #include "reader.h"
+#include "relay.h"
 
 #define PN532_UART UART_NUM_1
 #define PN532_TX_GPIO 3
@@ -20,163 +21,164 @@ 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);
+	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;
+	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;
+	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 bool read_logged(uint8_t *buffer, size_t len,
-                        int64_t deadline, const char *stage)
+						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;
-    }
+	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);
+	ESP_LOGI(TAG, "RX %s (%u bytes)", stage, (unsigned)len);
+	ESP_LOG_BUFFER_HEX_LEVEL(TAG, buffer, len, ESP_LOG_INFO);
 #endif
-    return true;
+	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_logged(&prefix[2], 1, deadline, "preamble byte")) {
-            return -1;
-        }
-    } while (prefix[0] != 0 ||
-             prefix[1] != 0 ||
-             prefix[2] != 0xff);
-
-    uint8_t lengths[2];
-    if (!read_logged(lengths, sizeof(lengths), deadline, "LEN/LCS")) {
-        return -1;
-    }
-
-    if (lengths[0] == 0 && lengths[1] == 0xff) {
-        uint8_t postamble;
-        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) {
-        ESP_LOGW(TAG, "invalid length: LEN=%u LCS=0x%02X capacity=%u",
-                 (unsigned)len, lengths[1], (unsigned)capacity);
-        return -1;
-    }
-
-    if (!read_logged(payload, len, deadline, "payload")) {
-        return -1;
-    }
-
-    uint8_t tail[2];
-    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];
-    }
-    if (checksum != 0) {
-        ESP_LOGW(TAG, "invalid checksum: sum=0x%02X", checksum);
-        return -1;
-    }
-
-    return (int)len;
+	uint8_t prefix[3] = {0};
+	do {
+		prefix[0] = prefix[1];
+		prefix[1] = prefix[2];
+		if (!read_logged(&prefix[2], 1, deadline, "preamble byte")) {
+			return -1;
+		}
+	} while (prefix[0] != 0 ||
+			 prefix[1] != 0 ||
+			 prefix[2] != 0xff);
+
+	uint8_t lengths[2];
+	if (!read_logged(lengths, sizeof(lengths), deadline, "LEN/LCS")) {
+		return -1;
+	}
+
+	if (lengths[0] == 0 && lengths[1] == 0xff) {
+		uint8_t postamble;
+		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) {
+		ESP_LOGW(TAG, "invalid length: LEN=%u LCS=0x%02X capacity=%u",
+				 (unsigned)len, lengths[1], (unsigned)capacity);
+		return -1;
+	}
+
+	if (!read_logged(payload, len, deadline, "payload")) {
+		return -1;
+	}
+
+	uint8_t tail[2];
+	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];
+	}
+	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). */
-static int command(const uint8_t *cmd, size_t cmd_len, uint8_t response[128])
+static int command_full(const uint8_t *cmd, size_t cmd_len, uint8_t *response,
+						size_t capacity, unsigned timeout_ms)
 {
-    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;
+	uint8_t frame[256];
+	if (cmd_len == 0 || cmd_len > sizeof(frame) - 8 || cmd_len + 1 >= 255) {
+		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;
 
 #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);
+	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
 
 	bool startup = cmd[0] == 0x02 || cmd[0] == 0x14;
@@ -202,55 +204,93 @@ static int command(const uint8_t *cmd, size_t cmd_len, uint8_t response[128])
 		}
 	}
 
-    int64_t deadline =
-        esp_timer_get_time() + COMMAND_TIMEOUT_MS * 1000;
-    int n;
+	int64_t deadline =
+		esp_timer_get_time() + (int64_t)timeout_ms * 1000;
+	int n;
 
-    do {
-        n = read_frame(response, 128, deadline);
+	do {
+		n = read_frame(response, capacity, 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);
-        }
+		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);
-        }
+		if (n < 0) {
+			ESP_LOGW(TAG, "RX failed or timed out: %d", n);
+		}
 #endif
-    } while (n == 0); /* ACK is not the command response. */
+	} while (n == 0); /* ACK is not the command response. */
 
-    if (n >= 2 &&
-        response[0] == 0xd5 &&
-        response[1] == (uint8_t)(cmd[0] + 1)) {
-        return n;
-    }
+	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]);
+	/* 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;
+}
+
+static int command(const uint8_t *cmd, size_t length, uint8_t response[128])
+{
+	return command_full(cmd, length, response, 128, COMMAND_TIMEOUT_MS);
+}
 
-    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));
+int pn532_exchange(uint8_t target, const uint8_t *apdu, size_t length, uint8_t *out,
+				   size_t capacity, size_t *out_length)
+{
+	*out_length = 0;
+	if (target == 0 || length < 4 || length > RELAY_APDU_MAX)
+		return 1;
+	uint8_t cmd[RELAY_APDU_MAX + 2] = {0x40, target};
+	uint8_t response[256];
+	memcpy(cmd + 2, apdu, length);
+	int n = command_full(cmd, length + 2, response, sizeof(response), 3000);
+	if (n < 3)
+		return 2;
+	/* Error code + MI/chaining flags. Chained PN532 responses are outside
+	 * this STANDARD-auth transport's bounded APDU contract. */
+	if (response[2] != 0) {
+		ESP_LOGW(TAG, "InDataExchange status=0x%02x", response[2]);
+		return 3;
+	}
+	if ((size_t)(n - 3) > capacity)
+		return 4;
+	memcpy(out, response + 3, n - 3);
+	*out_length = n - 3;
+	return 0;
+}
 
-    vTaskDelay(pdMS_TO_TICKS(20));
-    (void)uart_flush_input(PN532_UART);
-    return -1;
+static void release_rf(void)
+{
+	static const uint8_t rf_off[] = {0x32, 0x01, 0x00};
+	uint8_t response[128];
+	if (command(rf_off, sizeof(rf_off), response) != 2)
+		ESP_LOGW(TAG, "RF-off failed");
 }
 
 static bool initialize_reader(void)
 {
-    static const uint8_t firmware[] = {0x02};
-    static const uint8_t sam[] = {0x14, 0x01, 0x14, 0x01};
-    static const uint8_t retries[] = {0x32, 0x05, 0xff, 0x01, 0x00};
-    uint8_t response[128];
+	static const uint8_t firmware[] = {0x02};
+	static const uint8_t sam[] = {0x14, 0x01, 0x14, 0x01};
+	static const uint8_t retries[] = {0x32, 0x05, 0xff, 0x01, 0x00};
+	uint8_t response[128];
 
-    /* Discard stale input before starting this attempt. */
+	/* Discard stale input before starting this attempt. */
 	ESP_ERROR_CHECK(uart_flush_input(PN532_UART));
 
 	int n = command(firmware, sizeof(firmware), response);
@@ -280,54 +320,51 @@ static bool initialize_reader(void)
 /* 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;
+	static const uint8_t poll[] = {0x4a, 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->target = response[3];
+		card->sak = response[6];
+		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");
+	}
+	/* RF stays active through the callback's complete APDU session. */
+	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));
-    }
+	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");
+		}
+		release_rf();
+		vTaskDelay(pdMS_TO_TICKS(POLL_INTERVAL_MS));
+	}
 }
diff --git a/esp32/main/pn532_test.c b/esp32/main/pn532_test.c
index d561dde..0e14416 100644
--- a/esp32/main/pn532_test.c
+++ b/esp32/main/pn532_test.c
@@ -7,412 +7,412 @@
 #include "freertos/FreeRTOS.h"
 #include "freertos/task.h"
 
-#define PN532_UART      UART_NUM_1
+#define PN532_UART	  UART_NUM_1
 #define PN532_TX_GPIO   3
 #define PN532_RX_GPIO   4
-#define PN532_BAUD      115200
+#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);
+	printf("%s", prefix);
 
-    for (size_t i = 0; i < len; i++) {
-        printf("%02X ", buf[i]);
-    }
+	for (size_t i = 0; i < len; i++) {
+		printf("%02X ", buf[i]);
+	}
 
-    printf("\n");
+	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
-    );
+	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)
-    );
+	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)
-    );
+	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);
+	/*
+	 * 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,
+	static const uint8_t cmd[] = {
+		/* HSU wakeup */
+		0x55, 0x55,
+		0x00, 0x00, 0x00, 0x00, 0x00,
 
-        /* PN532 frame */
-        0xFF,
-        0x02, 0xFE,
-        0xD4, 0x02,
-        0x2A,
-        0x00
-    };
+		/* PN532 frame */
+		0xFF,
+		0x02, 0xFE,
+		0xD4, 0x02,
+		0x2A,
+		0x00
+	};
 
-    uint8_t buf[64];
+	uint8_t buf[64];
 
-    uart_flush_input(PN532_UART);
+	uart_flush_input(PN532_UART);
 
-    ESP_LOGI(TAG, "GetFirmwareVersion");
-    pn532_write(cmd, sizeof(cmd));
+	ESP_LOGI(TAG, "GetFirmwareVersion");
+	pn532_write(cmd, sizeof(cmd));
 
-    int len = pn532_read(buf, sizeof(buf), 1000);
+	int len = pn532_read(buf, sizeof(buf), 1000);
 
-    if (len <= 0) {
-        ESP_LOGE(TAG, "no response from PN532");
-        return;
-    }
+	if (len <= 0) {
+		ESP_LOGE(TAG, "no response from PN532");
+		return;
+	}
 
-    dump_hex("RX: ", buf, len);
+	dump_hex("RX: ", buf, len);
 
-    len = pn532_read(buf, sizeof(buf), 500);
+	len = pn532_read(buf, sizeof(buf), 500);
 
-    if (len > 0) {
-        dump_hex("RX: ", buf, len);
-    }
+	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);
+	int len = pn532_read(buf, size, timeout_ms);
 
-    if (len <= 0) {
-        return len;
-    }
+	if (len <= 0) {
+		return len;
+	}
 
-    dump_hex("RX: ", buf, len);
-    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");
+	/*
+	 * 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;
+	/*
+	 * 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");
+	ESP_LOGI(TAG, "PN532 NFC reader");
 
-    pn532_uart_init();
+	pn532_uart_init();
 
-    vTaskDelay(pdMS_TO_TICKS(1000));
+	vTaskDelay(pdMS_TO_TICKS(1000));
 
-    pn532_get_firmware_version();
+	pn532_get_firmware_version();
 
-    vTaskDelay(pdMS_TO_TICKS(100));
+	vTaskDelay(pdMS_TO_TICKS(100));
 
-    if (!pn532_sam_config()) {
-        ESP_LOGE(TAG, "PN532 initialization failed");
-        return;
-    }
+	if (!pn532_sam_config()) {
+		ESP_LOGE(TAG, "PN532 initialization failed");
+		return;
+	}
 
-    vTaskDelay(pdMS_TO_TICKS(100));
+	vTaskDelay(pdMS_TO_TICKS(100));
 
-    while (1) {
-        pn532_poll_card();
-        vTaskDelay(pdMS_TO_TICKS(300));
-    }
+	while (1) {
+		pn532_poll_card();
+		vTaskDelay(pdMS_TO_TICKS(300));
+	}
 }
diff --git a/esp32/main/reader.h b/esp32/main/reader.h
index 262e20d..f82c253 100644
--- a/esp32/main/reader.h
+++ b/esp32/main/reader.h
@@ -9,55 +9,60 @@
 #define NFC_UID_MAX 10
 
 typedef struct {
-    uint8_t uid[NFC_UID_MAX];
-    uint8_t uid_len;
+	uint8_t uid[NFC_UID_MAX];
+	uint8_t uid_len;
+	uint8_t target;
+	uint8_t sak;
 } 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);
+/* 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,
+				   uint8_t *out, size_t capacity, size_t *out_length);
 
 static inline bool nfc_uid_valid(size_t length)
 {
-    return length == 4 || length == 7 || length == 10;
+	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;
+	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_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)
+									   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;
+	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)
+									   const nfc_card_t *card)
 {
-    state->last = *card;
-    state->present = true;
+	state->last = *card;
+	state->present = true;
 }
 
 /* Nonconnectable legacy ADV: one 128-bit service-data AD, at most 31 bytes.
@@ -65,24 +70,24 @@ static inline void nfc_presence_accept(nfc_presence_t *state,
  * 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)
+									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));
+	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;
+	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
diff --git a/esp32/main/relay.c b/esp32/main/relay.c
new file mode 100644
index 0000000..ae1993a
--- /dev/null
+++ b/esp32/main/relay.c
@@ -0,0 +1,211 @@
+#include "relay.h"
+#include "esp_log.h"
+#include "esp_random.h"
+#include "esp_timer.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/queue.h"
+#include "freertos/semphr.h"
+#include "freertos/task.h"
+
+#define SESSION_TIMEOUT_US (15000000LL)
+typedef struct {
+	uint32_t session;
+	uint16_t seq, length;
+	uint8_t data[RELAY_APDU_MAX];
+} request_t;
+static SemaphoreHandle_t mutex;
+static QueueHandle_t requests;
+static bool connected, active, busy, ending;
+static uint32_t session_counter, session;
+static nfc_card_t current;
+static request_t assembling;
+static size_t received;
+static uint16_t last_seq;
+static uint8_t response[RELAY_VALUE_MAX];
+static size_t response_len;
+
+static uint16_t be16(const uint8_t *p) { return (uint16_t)((p[0] << 8) | p[1]); }
+static uint32_t be32(const uint8_t *p)
+{
+	return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | p[3];
+}
+static void put16(uint8_t *p, uint16_t n)
+{
+	p[0] = n >> 8;
+	p[1] = n;
+}
+static void put32(uint8_t *p, uint32_t n)
+{
+	p[0] = n >> 24;
+	p[1] = n >> 16;
+	p[2] = n >> 8;
+	p[3] = n;
+}
+static void header(uint8_t kind, uint32_t sid, uint16_t seq, uint16_t len)
+{
+	memset(response, 0, RELAY_HEADER);
+	response[0] = 1;
+	response[1] = kind;
+	put32(response + 2, sid);
+	put16(response + 6, seq);
+	put16(response + 10, len);
+	response_len = RELAY_HEADER + len;
+}
+void relay_init(void)
+{
+	mutex = xSemaphoreCreateMutex();
+	requests = xQueueCreate(1, sizeof(request_t));
+	configASSERT(mutex && requests);
+	session_counter = esp_random();
+}
+void relay_connected(bool value)
+{
+	xSemaphoreTake(mutex, portMAX_DELAY);
+	connected = value;
+	active = false;
+	ending = true;
+	busy = false;
+	received = 0;
+	response_len = 0;
+	xQueueReset(requests);
+	xSemaphoreGive(mutex);
+}
+size_t relay_status(uint8_t out[18])
+{
+	xSemaphoreTake(mutex, portMAX_DELAY);
+	memset(out, 0, 18);
+	out[0] = 1;
+	out[1] = active ? 1 : 0;
+	if (active) {
+		out[2] = current.uid_len;
+		out[3] = current.sak;
+		put32(out + 4, session);
+		memcpy(out + 8, current.uid, current.uid_len);
+	}
+	xSemaphoreGive(mutex);
+	return 18;
+}
+size_t relay_response(uint8_t out[RELAY_VALUE_MAX])
+{
+	xSemaphoreTake(mutex, portMAX_DELAY);
+	size_t n = response_len;
+	memcpy(out, response, n);
+	xSemaphoreGive(mutex);
+	return n;
+}
+bool relay_write(const uint8_t *p, size_t n)
+{
+	if (n < RELAY_HEADER || p[0] != 1)
+		return false;
+	uint32_t sid = be32(p + 2);
+	uint16_t seq = be16(p + 6), off = be16(p + 8), total = be16(p + 10);
+	bool ok = false;
+	xSemaphoreTake(mutex, portMAX_DELAY);
+	if (!connected || !active || ending || sid != session)
+		goto done;
+	if (p[1] == 2) {
+		if (n == RELAY_HEADER && off == 0 && total == 0) {
+			ending = true;
+			ok = true;
+		}
+		goto done;
+	}
+	if (p[1] != 1 || busy || total < 4 || total > RELAY_APDU_MAX || n == RELAY_HEADER ||
+		off > total || n - RELAY_HEADER > (size_t)(total - off))
+		goto done;
+	if (off == 0) {
+		if (seq == 0 || seq <= last_seq || received != 0)
+			goto done;
+		assembling.session = sid;
+		assembling.seq = seq;
+		assembling.length = total;
+	}
+	if (assembling.session != sid || assembling.seq != seq || assembling.length != total ||
+		off != received)
+		goto done;
+	memcpy(assembling.data + off, p + RELAY_HEADER, n - RELAY_HEADER);
+	received += n - RELAY_HEADER;
+	ok = true;
+	if (received == total) {
+		if (xQueueSend(requests, &assembling, 0) != pdTRUE) {
+			received = 0;
+			ok = false;
+			goto done;
+		}
+		busy = true;
+		last_seq = seq;
+		received = 0;
+		header(0x10, sid, seq, 0);
+	}
+done:
+	xSemaphoreGive(mutex);
+	return ok;
+}
+/* Runs synchronously inside the NFC callback: no scans or RF-off commands
+ * may run until this session has ended. No NimBLE calls from this task. */
+bool relay_card(const nfc_card_t *card)
+{
+	xSemaphoreTake(mutex, portMAX_DELAY);
+	if (!connected) {
+		xSemaphoreGive(mutex);
+		return false;
+	}
+	current = *card;
+	session = ++session_counter;
+	if (session == 0)
+		session = ++session_counter;
+	uint32_t sid = session;
+	active = true;
+	ending = false;
+	busy = false;
+	received = 0;
+	last_seq = 0;
+	response_len = 0;
+	xQueueReset(requests);
+	xSemaphoreGive(mutex);
+	ESP_LOGI("homekey-reader", "NFC session %lu opened; sak=0x%02x", (unsigned long)sid, card->sak);
+	int64_t deadline = esp_timer_get_time() + SESSION_TIMEOUT_US;
+	for (;;) {
+		xSemaphoreTake(mutex, portMAX_DELAY);
+		bool stop = !active || !connected || ending || session != sid;
+		xSemaphoreGive(mutex);
+		if (stop || esp_timer_get_time() >= deadline)
+			break;
+		request_t req;
+		if (xQueueReceive(requests, &req, pdMS_TO_TICKS(20)) != pdTRUE)
+			continue;
+		xSemaphoreTake(mutex, portMAX_DELAY);
+		bool valid = active && connected && !ending && session == sid && req.session == sid;
+		xSemaphoreGive(mutex);
+		if (!valid)
+			break;
+		uint8_t data[RELAY_APDU_MAX];
+		size_t length = 0;
+		int error = pn532_exchange(card->target, req.data, req.length, data, sizeof(data), &length);
+		xSemaphoreTake(mutex, portMAX_DELAY);
+		if (active && connected && session == sid && !ending) {
+			if (error == 0) {
+				header(0x11, sid, req.seq, length);
+				memcpy(response + RELAY_HEADER, data, length);
+			} else {
+				header(0x12, sid, req.seq, 1);
+				response[RELAY_HEADER] = (uint8_t)error;
+			}
+			busy = false;
+		}
+		xSemaphoreGive(mutex);
+		ESP_LOGI("homekey-reader", "APDU session=%lu seq=%u length=%u result=%d",
+				 (unsigned long)sid, req.seq, (unsigned)length, error);
+		if (error != 0)
+			break;
+	}
+	xSemaphoreTake(mutex, portMAX_DELAY);
+	if (session == sid) {
+		active = false;
+		busy = false;
+		received = 0;
+	}
+	xSemaphoreGive(mutex);
+	ESP_LOGI("homekey-reader", "NFC session %lu closed", (unsigned long)sid);
+	return true;
+}
diff --git a/esp32/main/relay.h b/esp32/main/relay.h
new file mode 100644
index 0000000..cc640a5
--- /dev/null
+++ b/esp32/main/relay.h
@@ -0,0 +1,15 @@
+#ifndef HOMEKEY_RELAY_H
+#define HOMEKEY_RELAY_H
+#include "reader.h"
+
+#define RELAY_APDU_MAX 240
+#define RELAY_HEADER 12
+#define RELAY_VALUE_MAX (RELAY_HEADER + RELAY_APDU_MAX)
+/* GATT callbacks are nonblocking. PN532/UART work stays in its owner task. */
+void relay_init(void);
+void relay_connected(bool connected);
+size_t relay_status(uint8_t out[18]);
+size_t relay_response(uint8_t out[RELAY_VALUE_MAX]);
+bool relay_write(const uint8_t *data, size_t length);
+bool relay_card(const nfc_card_t *card);
+#endif
diff --git a/tests/include/driver/uart.h b/tests/include/driver/uart.h
new file mode 100644
index 0000000..9820ce8
--- /dev/null
+++ b/tests/include/driver/uart.h
@@ -0,0 +1 @@
+#include "mock.h"
diff --git a/tests/include/esp_err.h b/tests/include/esp_err.h
new file mode 100644
index 0000000..9820ce8
--- /dev/null
+++ b/tests/include/esp_err.h
@@ -0,0 +1 @@
+#include "mock.h"
diff --git a/tests/include/esp_log.h b/tests/include/esp_log.h
new file mode 100644
index 0000000..9820ce8
--- /dev/null
+++ b/tests/include/esp_log.h
@@ -0,0 +1 @@
+#include "mock.h"
diff --git a/tests/include/esp_random.h b/tests/include/esp_random.h
new file mode 100644
index 0000000..9820ce8
--- /dev/null
+++ b/tests/include/esp_random.h
@@ -0,0 +1 @@
+#include "mock.h"
diff --git a/tests/include/esp_timer.h b/tests/include/esp_timer.h
new file mode 100644
index 0000000..9820ce8
--- /dev/null
+++ b/tests/include/esp_timer.h
@@ -0,0 +1 @@
+#include "mock.h"
diff --git a/tests/include/freertos/FreeRTOS.h b/tests/include/freertos/FreeRTOS.h
new file mode 100644
index 0000000..ca173d9
--- /dev/null
+++ b/tests/include/freertos/FreeRTOS.h
@@ -0,0 +1,3 @@
+#include "mock.h"
+#define portMAX_DELAY ((TickType_t)-1)
+#define configASSERT assert
diff --git a/tests/include/freertos/queue.h b/tests/include/freertos/queue.h
new file mode 100644
index 0000000..ecf3234
--- /dev/null
+++ b/tests/include/freertos/queue.h
@@ -0,0 +1,2 @@
+#include "mock.h"
+int xQueueReset(QueueHandle_t);
diff --git a/tests/include/freertos/semphr.h b/tests/include/freertos/semphr.h
new file mode 100644
index 0000000..378174b
--- /dev/null
+++ b/tests/include/freertos/semphr.h
@@ -0,0 +1,5 @@
+#include "mock.h"
+typedef void *SemaphoreHandle_t;
+SemaphoreHandle_t xSemaphoreCreateMutex(void);
+int xSemaphoreTake(SemaphoreHandle_t,TickType_t);
+int xSemaphoreGive(SemaphoreHandle_t);
diff --git a/tests/include/freertos/task.h b/tests/include/freertos/task.h
new file mode 100644
index 0000000..9820ce8
--- /dev/null
+++ b/tests/include/freertos/task.h
@@ -0,0 +1 @@
+#include "mock.h"
diff --git a/tests/include/mock.h b/tests/include/mock.h
new file mode 100644
index 0000000..7d58e95
--- /dev/null
+++ b/tests/include/mock.h
@@ -0,0 +1,47 @@
+#ifndef MOCK_H
+#define MOCK_H
+#include <assert.h>
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+#include <stdarg.h>
+typedef int esp_err_t;
+typedef unsigned TickType_t;
+#define ESP_OK 0
+#define ESP_ERR_NVS_NO_FREE_PAGES 1
+#define ESP_ERR_NVS_NEW_VERSION_FOUND 2
+#define ESP_LOG_INFO 1
+#define ESP_LOG_DEBUG 2
+#define ESP_ERROR_CHECK(e) assert((e)==ESP_OK)
+static inline void mock_log(const char *tag, const char *fmt, ...) {(void)tag;(void)fmt;}
+#define ESP_LOGI mock_log
+#define ESP_LOGW mock_log
+#define ESP_LOGE mock_log
+#define ESP_LOG_BUFFER_HEX_LEVEL(t,d,n,l) do {(void)(t);(void)(d);(void)(n);(void)(l);} while(0)
+#define pdMS_TO_TICKS(x) ((TickType_t)(x))
+#define pdTRUE 1
+#define UART_NUM_1 1
+#define UART_DATA_8_BITS 8
+#define UART_PARITY_DISABLE 0
+#define UART_STOP_BITS_1 1
+#define UART_HW_FLOWCTRL_DISABLE 0
+#define UART_SCLK_DEFAULT 0
+#define UART_PIN_NO_CHANGE -1
+typedef struct {int baud_rate,data_bits,parity,stop_bits,flow_ctrl,source_clk;} uart_config_t;
+int uart_driver_install(int,int,int,int,void*,int);
+int uart_param_config(int,const uart_config_t*);
+int uart_set_pin(int,int,int,int,int);
+int uart_flush_input(int);
+int uart_write_bytes(int,const void*,size_t);
+int uart_wait_tx_done(int,TickType_t);
+int uart_read_bytes(int,void*,size_t,TickType_t);
+int64_t esp_timer_get_time(void);
+void vTaskDelay(TickType_t);
+uint32_t esp_random(void);
+int nvs_flash_init(void);
+int nvs_flash_erase(void);
+typedef void *QueueHandle_t;
+QueueHandle_t xQueueCreate(unsigned,unsigned);
+int xQueueSend(QueueHandle_t,const void*,TickType_t);
+int xQueueReceive(QueueHandle_t,void*,TickType_t);
+#endif
diff --git a/tests/pn532_test.c b/tests/pn532_test.c
new file mode 100644
index 0000000..2e88dc7
--- /dev/null
+++ b/tests/pn532_test.c
@@ -0,0 +1,175 @@
+#include <stdio.h>
+#include "../esp32/main/pn532.c"
+static uint8_t stream[1024];
+static size_t total, pos, chunk = 1;
+static int64_t now;
+static unsigned writes;
+static uint8_t last_tx[300];
+static size_t last_tx_len;
+int64_t esp_timer_get_time(void) { return now; }
+void vTaskDelay(TickType_t t) { now += t * 1000; }
+int uart_read_bytes(int u, void *dst, size_t len, TickType_t t)
+{
+	(void)u;
+	now += 100;
+	if (pos == total) {
+		now += t * 1000;
+		return 0;
+	}
+	size_t n = total - pos;
+	if (n > len)
+		n = len;
+	if (n > chunk)
+		n = chunk;
+	memcpy(dst, stream + pos, n);
+	pos += n;
+	return (int)n;
+}
+int uart_write_bytes(int u, const void *d, size_t n)
+{
+	(void)u;
+	assert(n <= sizeof(last_tx));
+	memcpy(last_tx, d, n);
+	last_tx_len = n;
+	++writes;
+	return (int)n;
+}
+int uart_wait_tx_done(int u, TickType_t t)
+{
+	(void)u;
+	(void)t;
+	return 0;
+}
+int uart_flush_input(int u)
+{
+	(void)u;
+	pos = total;
+	return 0;
+}
+int uart_driver_install(int a, int b, int c, int d, void *e, int f)
+{
+	(void)a;
+	(void)b;
+	(void)c;
+	(void)d;
+	(void)e;
+	(void)f;
+	return 0;
+}
+int uart_param_config(int u, const uart_config_t *c)
+{
+	(void)u;
+	(void)c;
+	return 0;
+}
+int uart_set_pin(int a, int b, int c, int d, int e)
+{
+	(void)a;
+	(void)b;
+	(void)c;
+	(void)d;
+	(void)e;
+	return 0;
+}
+static void reset(void)
+{
+	total = pos = 0;
+	now = 0;
+	writes = 0;
+}
+static void append(const uint8_t *d, size_t n)
+{
+	memcpy(stream + total, d, n);
+	total += n;
+}
+static void response(const uint8_t *d, uint8_t n)
+{
+	uint8_t h[] = {0, 0, 255, n, (uint8_t)(0 - n)};
+	append(ack, sizeof(ack));
+	append(h, 5);
+	append(d, n);
+	uint8_t sum = 0;
+	for (unsigned i = 0; i < n; i++)
+		sum += d[i];
+	uint8_t tail[] = {(uint8_t)(0 - sum), 0};
+	append(tail, 2);
+}
+int main(void)
+{
+	uint8_t out[128];
+	const uint8_t fw[] = {0xd5, 3, 0x32, 1, 6, 7}, cmd[] = {2};
+	for (chunk = 1; chunk <= 64; chunk *= 2) {
+		reset();
+		response(fw, 6);
+		assert(command(cmd, 1, out) == 6);
+		assert(memcmp(out, fw, 6) == 0);
+		assert(writes == 1);
+		assert(last_tx_len == 14 && memcmp(last_tx, "\x55\x55\0\0\0", 5) == 0);
+	}
+	reset();
+	response(fw, 6);
+	stream[total - 2] ^= 1;
+	assert(command(cmd, 1, out) == -1);
+	assert(writes == 2);
+	reset();
+	response(fw, 6);
+	stream[sizeof(ack) + 4] ^= 1;
+	assert(command(cmd, 1, out) == -1);
+	reset();
+	response(fw, 6);
+	--total;
+	assert(command(cmd, 1, out) == -1);
+	reset();
+	append(ack, sizeof(ack));
+	assert(command(cmd, 1, out) == -1);
+	const uint8_t rf[] = {0xd5, 0x33};
+	const unsigned lens[] = {4, 7, 10};
+	for (unsigned k = 0; k < 3; k++) {
+		uint8_t target[18] = {0xd5, 0x4b, 1, 1, 0, 4, 0};
+		target[7] = lens[k];
+		for (unsigned j = 0; j < lens[k]; j++)
+			target[8 + j] = j + 1;
+		reset();
+		response(target, 8 + lens[k]);
+		response(rf, 2);
+		nfc_card_t card = {0};
+		assert(poll_card(&card) == 1);
+		assert(card.uid_len == lens[k]);
+		assert(memcmp(card.uid, target + 8, lens[k]) == 0);
+	}
+	uint8_t absent[] = {0xd5, 0x4b, 0};
+	reset();
+	response(absent, 3);
+	response(rf, 2);
+	nfc_card_t card = {0};
+	assert(poll_card(&card) == 0);
+	uint8_t short_uid[] = {0xd5, 0x4b, 1, 1, 0, 4, 0, 10, 1};
+	reset();
+	response(short_uid, sizeof(short_uid));
+	response(rf, 2);
+	assert(poll_card(&card) == -1);
+	reset();
+	response(absent, 3);
+	stream[total - 2] ^= 1;
+	assert(poll_card(&card) == -1);
+	uint8_t apdu[114] = {0x80, 0x80, 0, 1}, reply[] = {0xd5, 0x41, 0, 0x90, 0};
+	size_t out_length = 0;
+	reset();
+	response(reply, sizeof(reply));
+	assert(pn532_exchange(1, apdu, sizeof(apdu), out, sizeof(out), &out_length) == 0);
+	assert(out_length == 2 && out[0] == 0x90);
+	assert(last_tx_len == sizeof(apdu) + 10);
+	assert(last_tx[5] == 0xd4 && last_tx[6] == 0x40 && last_tx[7] == 1);
+	assert(memcmp(last_tx + 8, apdu, sizeof(apdu)) == 0);
+	reset();
+	reply[2] = 1;
+	response(reply, sizeof(reply));
+	assert(pn532_exchange(1, apdu, sizeof(apdu), out, sizeof(out), &out_length) == 3);
+	reset();
+	reply[2] = 0x40;
+	response(reply, sizeof(reply));
+	assert(pn532_exchange(1, apdu, sizeof(apdu), out, sizeof(out), &out_length) == 3);
+	assert(pn532_exchange(1, apdu, RELAY_APDU_MAX + 1, out, sizeof(out), &out_length) == 1);
+	puts("PN532: startup prefix preserved; APDU >64 bytes; status/chaining rejection; fragmented "
+		 "frames, checksums, timeout and UID parsing passed");
+}
diff --git a/tests/relay_test.c b/tests/relay_test.c
new file mode 100644
index 0000000..0fa85a8
--- /dev/null
+++ b/tests/relay_test.c
@@ -0,0 +1,123 @@
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include "../esp32/main/relay.c"
+static request_t queued;
+static bool have_queued;
+static int phase, scenario, exchanges;
+static int64_t clock_us;
+SemaphoreHandle_t xSemaphoreCreateMutex(void) { return (void *)1; }
+int xSemaphoreTake(SemaphoreHandle_t m, TickType_t t)
+{
+	(void)m;
+	(void)t;
+	return pdTRUE;
+}
+int xSemaphoreGive(SemaphoreHandle_t m)
+{
+	(void)m;
+	return pdTRUE;
+}
+QueueHandle_t xQueueCreate(unsigned n, unsigned size)
+{
+	assert(n == 1 && size == sizeof(request_t));
+	return (void *)1;
+}
+int xQueueReset(QueueHandle_t q)
+{
+	(void)q;
+	have_queued = false;
+	return pdTRUE;
+}
+int xQueueSend(QueueHandle_t q, const void *p, TickType_t t)
+{
+	(void)q;
+	(void)t;
+	if (have_queued)
+		return 0;
+	queued = *(const request_t *)p;
+	have_queued = true;
+	return pdTRUE;
+}
+uint32_t esp_random(void) { return 100; }
+int64_t esp_timer_get_time(void) { return clock_us; }
+static size_t make_request(uint8_t out[32], uint8_t kind, uint32_t sid, uint16_t seq, uint16_t off,
+						   uint16_t len, const uint8_t *d, size_t n)
+{
+	memset(out, 0, RELAY_HEADER);
+	out[0] = 1;
+	out[1] = kind;
+	put32(out + 2, sid);
+	put16(out + 6, seq);
+	put16(out + 8, off);
+	put16(out + 10, len);
+	if (n)
+		memcpy(out + 12, d, n);
+	return 12 + n;
+}
+int xQueueReceive(QueueHandle_t q, void *p, TickType_t t)
+{
+	(void)q;
+	clock_us += t * 1000;
+	if (phase == 0) {
+		uint8_t s[18], b[32], d[] = {0, 0xa4, 4, 0};
+		relay_status(s);
+		assert(s[1] == 1 && be32(s + 4) == session && s[3] == 0x20);
+		assert(!relay_write(b, make_request(b, 1, session - 1, 1, 0, 4, d, 4)));
+		assert(!relay_write(b, make_request(b, 1, session, 1, 2, 4, d + 2, 2)));
+		assert(relay_write(b, make_request(b, 1, session, 1, 0, 4, d, 2)));
+		assert(!relay_write(b, make_request(b, 1, session, 1, 0, 4, d, 2))); // Duplicate offset.
+		assert(!relay_write(b, make_request(b, 1, session, 2, 2, 4, d + 2, 2))); // Wrong sequence.
+		assert(relay_write(b, make_request(b, 1, session, 1, 2, 4, d + 2, 2)));
+		assert(!relay_write(b, make_request(b, 1, session, 2, 0, 4, d, 4))); // Busy.
+		uint8_t r[RELAY_VALUE_MAX];
+		assert(relay_response(r) == 12 && r[1] == 0x10);
+		phase = 1;
+	} else if (phase == 1) {
+		uint8_t r[RELAY_VALUE_MAX], b[32], d[] = {0x80, 0x3c, 0, 0};
+		size_t n = relay_response(r);
+		assert(n == 14 && r[1] == 0x11 && be16(r + 6) == 1 && r[12] == 0x90 && r[13] == 0);
+		assert(!relay_write(b, make_request(b, 1, session, 1, 0, 4, d, 4))); // Replay.
+		if (scenario == 1) {
+			relay_connected(false);
+		} else if (scenario == 2) {
+			clock_us += SESSION_TIMEOUT_US;
+		} else {
+			assert(relay_write(b, make_request(b, 2, session, 0, 0, 0, NULL, 0)));
+		}
+		phase = 2;
+	}
+	if (!have_queued)
+		return 0;
+	*(request_t *)p = queued;
+	have_queued = false;
+	return pdTRUE;
+}
+int pn532_exchange(uint8_t target, const uint8_t *apdu, size_t len, uint8_t *out, size_t capacity,
+				   size_t *n)
+{
+	assert(target == 1 && len == 4 && apdu[1] == 0xa4 && capacity >= 2);
+	exchanges++;
+	out[0] = 0x90;
+	out[1] = 0;
+	*n = 2;
+	return 0;
+}
+int main(void)
+{
+	relay_init();
+	nfc_card_t card = {.uid = {1, 2, 3, 4}, .uid_len = 4, .target = 1, .sak = 0x20};
+	assert(!relay_card(&card));
+	for (scenario = 0; scenario < 3; scenario++) {
+		relay_connected(true);
+		phase = exchanges = 0;
+		clock_us = 0;
+		assert(relay_card(&card));
+		assert(exchanges == 1 && !active);
+		uint8_t s[18];
+		relay_status(s);
+		assert(s[1] == 0);
+	}
+	puts("Relay: fragment order, busy rejection, stale session, replay, response, release, "
+		 "disconnect and timeout passed");
+}
diff --git a/tests/run.sh b/tests/run.sh
new file mode 100755
index 0000000..8cc7a98
--- /dev/null
+++ b/tests/run.sh
@@ -0,0 +1,9 @@
+#!/bin/sh
+set -eu
+cd "$(dirname "$0")"
+build_dir=$(mktemp -d)
+trap 'rm -rf "$build_dir"' EXIT
+for name in pn532 relay; do
+	cc -std=c11 -Wall -Wextra -Werror -fsanitize=address,undefined -g -Iinclude "${name}_test.c" -o "$build_dir/$name"
+	"$build_dir/$name"
+done