Python SDK for interacting with NFC readers via the nfc-agent local server.
pip install nfc-agent- Python 3.9+
- Running nfc-agent server
import asyncio
from nfc_agent import NFCClient
async def main():
async with NFCClient() as client:
# List readers
readers = await client.get_readers()
print(f"Found {len(readers)} reader(s)")
# Read card
try:
card = await client.read_card(0)
print(f"Card UID: {card.uid}")
print(f"Card Type: {card.type}")
if card.data:
print(f"Data: {card.data}")
except Exception as e:
print(f"No card present: {e}")
asyncio.run(main())from nfc_agent import NFCClient
with NFCClient() as client:
readers = client.get_readers_sync()
card = client.read_card_sync(0)
print(f"Card UID: {card.uid}")import asyncio
from nfc_agent import NFCWebSocket
async def main():
async with NFCWebSocket() as ws:
# Subscribe to reader events
await ws.subscribe(0)
@ws.on_card_detected
def handle_card(event):
print(f"Card detected: {event.card.uid}")
@ws.on_card_removed
def handle_removed(event):
print(f"Card removed from reader {event.reader}")
# Keep running
await asyncio.sleep(60)
asyncio.run(main())import asyncio
from nfc_agent import NFCClient
async def main():
async with NFCClient() as client:
poller = client.poll_card(0, interval=0.5)
@poller.on_card
def handle_card(card):
print(f"Card: {card.uid}")
@poller.on_removed
def handle_removed():
print("Card removed")
await poller.start()
await asyncio.sleep(30)
poller.stop()
asyncio.run(main())REST API client for simple request/response operations.
from nfc_agent import NFCClient
# With context manager (recommended)
async with NFCClient(base_url="http://127.0.0.1:32145", timeout=5.0) as client:
...
# Or sync
with NFCClient() as client:
...| Method | Description |
|---|---|
get_readers() |
List available NFC readers |
read_card(reader_index, *, refresh=False) |
Read card metadata + NDEF. Fast — use for detection/polling. Pass refresh=True to bypass cache. |
write_card(reader_index, *, data, data_type, url) |
Write data to a card |
get_version() |
Get agent version information |
is_connected() |
Check if agent is running |
poll_card(reader_index, *, interval) |
Create a card poller |
| Method | Description |
|---|---|
read_mifare_block(reader_index, block, *, key, key_type) |
Read 16-byte block |
write_mifare_block(reader_index, block, *, data, key, key_type) |
Write 16-byte block |
write_mifare_blocks(reader_index, blocks, *, key, key_type) |
Batch write blocks |
derive_uid_key_aes(reader_index, aes_key) |
Derive key from UID |
aes_encrypt_and_write_block(...) |
AES encrypt and write |
write_mifare_sector_trailer(...) |
Write sector trailer |
| Method | Description |
|---|---|
read_ultralight_page(reader_index, page, *, password) |
Read 4-byte page |
write_ultralight_page(reader_index, page, *, data, password) |
Write 4-byte page |
write_ultralight_pages(reader_index, pages, *, password) |
Batch write pages |
WebSocket client for real-time communication and events.
from nfc_agent import NFCWebSocket
async with NFCWebSocket(
url="ws://127.0.0.1:32145/v1/ws",
timeout=5.0,
auto_reconnect=True,
reconnect_interval=3.0,
secure=False # Use wss:// for HTTPS pages
) as ws:
...All methods from NFCClient, plus:
| Method | Description |
|---|---|
subscribe(reader_index, *, include_raw=False) |
Subscribe to card events. Pass include_raw=True to also receive on_card_data events with full memory dump. |
unsubscribe(reader_index) |
Unsubscribe from events |
read_card_full(reader_index) |
Unified read — metadata + NDEF + full raw memory dump. Slow — call once on demand, not in a poll loop. |
dump_card(reader_index) |
Raw memory dump only (pages for NTAG, blocks for MIFARE Classic; no NDEF metadata) |
erase_card(reader_index) |
Erase NDEF data |
lock_card(reader_index) |
Permanently lock card |
set_password(reader_index, password) |
Set NTAG password |
remove_password(reader_index, password) |
Remove NTAG password |
write_records(reader_index, records) |
Write multiple NDEF records |
health() |
Health check |
| Method | Description |
|---|---|
open_desfire_session(reader_index) |
Open a transparent DESFire APDU session (holds the card connection open) |
desfire_transmit(reader_index, *, apdu) |
Transmit one APDU (hex), returns {response, sw1, sw2} |
desfire_transmit_batch(reader_index, *, apdus) |
Transmit multiple APDUs in order, returns {responses: [...]} |
close_desfire_session(reader_index) |
Close the DESFire session |
@ws.on_card_detected
def handle_card(event):
print(event.card.uid)
@ws.on_card_data
def handle_data(event):
# event.pages (NTAG) or event.blocks (MIFARE Classic)
print(event.pages)
@ws.on_card_removed
def handle_removed(event):
print(f"Removed from reader {event.reader}")
@ws.on_readers_changed
def handle_readers(event):
# Fired when readers are plugged in/removed, or pcscd becomes available
# after startup. event.readers is the full current reader list.
print(f"Readers now connected: {event.readers}")
@ws.on_connected
def handle_connected():
print("Connected")
@ws.on_disconnected
def handle_disconnected():
print("Disconnected")
@ws.on_error
def handle_error(error):
print(f"Error: {error}")Polls a reader for card presence.
poller = client.poll_card(reader_index, interval=1.0)
@poller.on_card
def handle_card(card):
print(card.uid)
@poller.on_removed
def handle_removed():
print("Removed")
@poller.on_error
def handle_error(e):
print(f"Error: {e}")
await poller.start()
# ...
poller.stop()from nfc_agent import (
Reader,
Card,
CardDataType,
VersionInfo,
HealthInfo,
CardDetectedEvent,
CardRemovedEvent,
MifareKeyType,
MifareBlockData,
UltralightPageData,
NDEFRecord,
)from nfc_agent import (
NFCAgentError, # Base exception
ConnectionError, # Connection failed
CardError, # Card operation failed
DesfireError, # DESFire session failed (subclass of CardError, optional .status_code)
APIError, # API returned error
TimeoutError, # Request timed out
ReaderError, # Reader issue
)async with NFCClient() as client:
await client.write_card(0, data="https://example.com", data_type="url")async with NFCClient() as client:
await client.write_card(0, data="Hello World", data_type="text")import json
async with NFCClient() as client:
data = json.dumps({"user_id": 123, "name": "Alice"})
await client.write_card(0, data=data, data_type="json")from nfc_agent import MifareKeyType
async with NFCClient() as client:
block = await client.read_mifare_block(
0,
block=4,
key="FFFFFFFFFFFF",
key_type=MifareKeyType.A
)
print(f"Block {block.block}: {block.data}")For DESFire EV2/EV3 cards, the SDK exposes a transparent APDU session (WebSocket only — there is no REST equivalent). The agent holds the card connection open across messages so an external party — typically the SimplyPrint backend, which keeps the DESFire keys in its HSM — can drive an interactive AuthenticateEV2First handshake (a 3-pass challenge/response that can't be pre-computed) and the secure-messaging commands that follow.
The agent performs no DESFire cryptography and holds no keys. Your code sends raw APDU bytes and gets the card's raw response (including the status word) back; all session secrets stay on your side. Send native DESFire APDUs (CLA=0x90) — the real commands come from the backend. Don't subscribe a reader while a session is open on it (unsubscribe first), and note sessions are dropped automatically on disconnect. Recommended readers are the ACR1252U / ACR1552U class.
from nfc_agent import NFCWebSocket, DesfireError
async with NFCWebSocket() as ws:
# Open the session — agent keeps the card connection open across calls
session = await ws.open_desfire_session(0)
print(f"UID: {session.uid} ATR: {session.atr}")
try:
# Transmit one APDU. Use single transmits for the interactive auth handshake.
# (Here: the get-UID pseudo-APDU, answered by any ISO 14443-A card.)
resp = await ws.desfire_transmit(0, apdu="ffca000000")
print(resp.response, hex(resp.sw1), hex(resp.sw2)) # full reply incl. status word
# Batch transmit for non-interactive stretches
batch = await ws.desfire_transmit_batch(0, apdus=["ffca000000", "ffca000000"])
for r in batch.responses:
print(r.response, hex(r.sw1), hex(r.sw2))
except DesfireError as e:
print(f"DESFire error: {e} status={e.status_code}")
finally:
await ws.close_desfire_session(0)apdu and response are hex strings; response includes the trailing status word. Failures raise DesfireError (a subclass of CardError) with an optional status_code. See scripts/test_desfire_session.py for a card-agnostic smoke test.
async with NFCWebSocket() as ws:
# Subscribe to all readers
readers = await ws.get_readers()
for i, reader in enumerate(readers):
await ws.subscribe(i)
print(f"Subscribed to {reader.name}")
@ws.on_card_detected
def handle(event):
print(f"Reader {event.reader}: {event.card.uid}")
await asyncio.sleep(300)# Install dev dependencies
pip install -e ".[dev]"
# Run tests
pytest
# Lint
ruff check src tests
# Type check
mypy srcMIT