This guide explains how to add support for new NFC readers or tag types to the davi-nfc-agent.
┌─────────────────────────────────────────────────────────────┐
│ MultiManager │
│ Aggregates multiple managers, routes device requests │
├──────────────────┬──────────────────┬───────────────────────┤
│ HardwareManager │ remotenfc.Manager│ YourManager │
│ (PC/SC readers) │ (WebNFC/mobile) │ (custom) │
└────────┬─────────┴────────┬─────────┴──────────┬────────────┘
│ │ │
▼ ▼ ▼
Device Device Device
│ │ │
▼ ▼ ▼
Tag[] Tag[] Tag[]
A backend implements no authentication of its own. Devices that dial in reach it
through pairing.Gate.Admit, which wraps the backend’s endpoint at the mount,
checks the credential and names the device it admitted; the backend reads that
name with deviceid.Of(r) and registers the device under it, or mints one when
nothing admitted the connection.
The gate is not in this tree. It wraps endpoints, so a backend with none — a
reader attached to this machine — is untouched by it. What it asks of the tree
is DisconnectDevice, so revoking a credential ends the session holding it.
| Interface | Purpose |
|---|---|
Manager |
Device discovery and connection |
Device |
Hardware communication |
Tag |
Tag operations (read/write/transceive) |
package myreader
import "github.com/dotside-studios/davi-nfc-agent/nfc"
type MyManager struct {
// Your connection state (USB, serial, network, etc.)
}
func NewManager() *MyManager {
return &MyManager{}
}
// Devices lists what this manager offers, and what is known of each before it
// is opened. Capabilities.CanPoll says the agent may open it and read from it;
// a device that reports its own scans declares SupportsEvents instead and is
// left out of every reader list.
func (m *MyManager) Devices() ([]nfc.DeviceListing, error) {
// Enumerate connected devices, naming them "myreader:usb:001" or
// "myreader:192.168.1.100".
return []nfc.DeviceListing{
{
Path: "myreader:default",
Capabilities: nfc.DeviceCapabilities{CanPoll: true, CanTransceive: true},
},
}, nil
}
// OpenDevice opens a device by its identifier
// The device should be fully initialized and ready to use when returned
func (m *MyManager) OpenDevice(deviceStr string) (nfc.Device, error) {
// Parse deviceStr and connect to the hardware
device := &MyDevice{
connection: deviceStr,
}
// Perform any device-specific initialization here
// The returned device should be ready to use immediately
return device, nil
}
type MyDevice struct {
connection string
// Your hardware handle (serial port, USB handle, socket, etc.)
}
func (d *MyDevice) Close() error {
// Clean up resources
return nil
}
func (d *MyDevice) String() string {
return "My NFC Reader"
}
func (d *MyDevice) Connection() string {
return d.connection
}
// DeviceType returns the device type identifier (implements DeviceInfoProvider)
func (d *MyDevice) DeviceType() string {
return "myreader"
}
// SupportedTagTypes returns supported tag types (implements DeviceInfoProvider)
func (d *MyDevice) SupportedTagTypes() []string {
return []string{"MIFARE Classic", "NTAG"}
}
func (d *MyDevice) Transceive(txData []byte) ([]byte, error) {
// Send raw bytes to the reader and return response
// This is for device-level commands, not tag communication
return nil, nfc.NewNotSupportedError("Transceive")
}
func (d *MyDevice) GetTags() ([]nfc.Tag, error) {
// Poll for tags on the reader
// Return detected tags
// Example: detect a tag and wrap it
tagUID := "04A1B2C3D4E5F6"
tagType := "MIFARE Classic 1K"
tag := &MyTag{
uid: tagUID,
tagType: tagType,
device: d,
}
return []nfc.Tag{tag}, nil
}
The Tag interface bundles several role interfaces (identity, connection, read,
write, transceive, lock). Rather than implement all of them, embed
nfc.BaseTag and override only the methods your tag actually supports. The
base provides safe defaults: connection is a no-op, and write/transceive/lock
operations report “not supported”.
You only ever need to implement the four methods that have no sensible default:
UID(), Type(), NumericType(), and ReadData().
type MyTag struct {
nfc.BaseTag // supplies Connect/Disconnect/WriteData/Transceive/IsWritable/CanMakeReadOnly/MakeReadOnly
uid string
tagType string
device *MyDevice
}
// --- TagIdentifier (required) ---
func (t *MyTag) UID() string {
return t.uid
}
func (t *MyTag) Type() string {
return t.tagType
}
func (t *MyTag) NumericType() int {
return 0 // Your type code
}
// --- TagReader (required) ---
func (t *MyTag) ReadData() ([]byte, error) {
// Read NDEF data from the tag and return raw NDEF bytes.
return nil, nil
}
// --- TagCapabilityProvider (optional but recommended) ---
func (t *MyTag) Capabilities() nfc.TagCapabilities {
return nfc.TagCapabilities{
CanRead: true,
CanWrite: true,
CanTransceive: false,
CanLock: false,
TagFamily: "MIFARE Classic",
Technology: "ISO14443A",
MemorySize: 1024,
SupportsNDEF: true,
}
}
// --- Override only what you support ---
// If your tag is writable, override WriteData (otherwise it inherits the
// BaseTag default that returns a NotSupported error):
func (t *MyTag) WriteData(data []byte) error {
// Write NDEF data to the tag.
return nil
}
// Connect/Disconnect/Transceive/IsWritable/CanMakeReadOnly/MakeReadOnly are all
// inherited from nfc.BaseTag. Override any of them the same way if your tag
// supports them.
Keep
Capabilities()in sync with what you actually override: if you advertiseCanWrite: true, make sure you overrideWriteData. See Capability-Based Implementation for a test helper that catches drift.
ReadData must not report an absent NDEF message by returning zero bytes and a
nil error, which is indistinguishable from a successful read of an empty tag.
Return nfc.NewNoPayloadError instead:
func (t *MyTag) ReadData() ([]byte, error) {
data, err := t.read()
if err != nil {
return nil, err
}
ndef, found := nfc.TLVFindNDEF(data)
if !found {
return nil, nfc.NewNoPayloadError("ReadData", t.uid, nil)
}
return ndef, nil
}
The reader publishes such a tag as a scan carrying its identity alone. A tag
that never carries NDEF should also report SupportsNDEF: false, which skips
the read.
In your main.go or initialization code:
import (
"github.com/dotside-studios/davi-nfc-agent/nfc"
"github.com/dotside-studios/davi-nfc-agent/nfc/multimanager"
"myproject/myreader"
)
func main() {
manager := multimanager.NewMultiManager(
multimanager.ManagerEntry{Name: nfc.ManagerTypeHardware, Manager: pcsc.NewManager()},
multimanager.ManagerEntry{Name: "myreader", Manager: myreader.NewManager()},
)
// Use the manager...
}
Implement DeviceChangeNotifier to notify the system when devices are added or removed:
type MyManager struct {
devices map[string]*MyDevice
devicesChan chan struct{}
mu sync.RWMutex
}
// DeviceChanges returns a channel that signals when devices change.
// Implements nfc.DeviceChangeNotifier.
func (m *MyManager) DeviceChanges() <-chan struct{} {
return m.devicesChan
}
// Call this when a device is added or removed
func (m *MyManager) notifyDeviceChange() {
select {
case m.devicesChan <- struct{}{}:
default:
// Channel full, skip notification
}
}
The MultiManager automatically listens to managers that implement DeviceChangeNotifier and forwards change notifications.
You don’t need to implement all methods if your device doesn’t support them.
Embed nfc.BaseTag so unsupported operations already return the right
“not supported” error, then advertise what is supported via Capabilities():
type MyTag struct {
nfc.BaseTag // WriteData/Transceive/MakeReadOnly default to "not supported"
// ...
}
func (t *MyTag) Capabilities() nfc.TagCapabilities {
return nfc.TagCapabilities{
CanRead: true,
CanWrite: false, // Read-only device; no need to override WriteData
CanTransceive: false,
CanLock: false,
}
}
Because the defaults come from BaseTag, a read-only tag literally just needs
UID, Type, NumericType, and ReadData. There is no WriteData
boilerplate
to write.
Capabilities() and actual method behavior are two separate sources of truth, so
they can drift. Drop nfc.AssertCapabilitiesConsistent into your tests to catch
the common cases (it performs only non-mutating checks and never writes or locks
the tag):
func TestMyTagCapabilities(t *testing.T) {
tag := &MyTag{ /* ... */ }
if err := nfc.AssertCapabilitiesConsistent(tag); err != nil {
t.Fatal(err)
}
}
Callers can check capabilities before calling methods:
caps := nfc.GetTagCapabilities(tag)
if caps.CanWrite {
tag.WriteData(data)
} else {
log.Println("Tag does not support writing")
}
Use these convenience functions for common capability checks:
// Check tag capabilities
if nfc.CanTagRead(tag) {
data, _ := tag.ReadData()
}
if nfc.CanTagWrite(tag) {
tag.WriteData(data)
}
if nfc.CanTagTransceive(tag) {
resp, _ := tag.Transceive(apdu)
}
if nfc.CanTagLock(tag) {
tag.MakeReadOnly()
}
Use GetDeviceCapabilities() to inspect device capabilities:
caps := nfc.GetDeviceCapabilities(device)
// DeviceCapabilities struct:
// - CanTransceive: bool // Device supports raw transceive
// - CanPoll: bool // Device supports polling for tags
// - SupportedTagTypes: []string // e.g., ["MIFARE Classic", "NTAG"]
// - DeviceType: string // e.g., "libnfc", "smartphone"
// - MaxBaudRate: int // Max baud rate in bps
// - SupportsEvents: bool // Tag arrival/removal events
if caps.SupportsEvents {
// Event-driven device (e.g., smartphone)
} else if caps.CanPoll {
// Polling device (e.g., hardware reader)
}
Capabilities are automatically built from optional interfaces the device implements (DeviceInfoProvider, DeviceEventEmitter).
Use the structured error types for consistent error handling:
import "github.com/dotside-studios/davi-nfc-agent/nfc"
// For unsupported operations
return nfc.NewNotSupportedError("Transceive")
// For authentication failures
return nfc.NewAuthError("ReadData", tag.UID(), err)
// For read/write failures
return nfc.NewReadError("ReadData", err)
return nfc.NewWriteError("WriteData", err)
// For generic errors with context
return nfc.WrapError(nfc.ErrCodeReadFailed, "ReadSector", "failed to read sector 1", err)
Callers can handle errors programmatically:
if nfc.IsNotSupportedError(err) {
// Operation not supported, try alternative
}
if nfc.IsAuthError(err) {
// Authentication failed, maybe try different key
}
code := nfc.GetErrorCode(err)
switch code {
case nfc.ErrCodeTagRemoved:
// Tag was removed, retry
case nfc.ErrCodeReadFailed:
// Read failed, handle error
}
For tags that need special write handling, implement the AdvancedWriter interface:
// TagWriteOptions controls write behavior
type TagWriteOptions struct {
// ForceInitialize wipes and reinitializes the tag before writing.
// WARNING: This erases all existing data.
ForceInitialize bool
}
// AdvancedWriter is an optional interface for tags supporting write options.
// Implement WriteDataWithOptions to handle special write cases.
func (t *MyTag) WriteDataWithOptions(data []byte, opts nfc.TagWriteOptions) error {
if opts.ForceInitialize {
// Wipe and reinitialize the tag
if err := t.format(); err != nil {
return err
}
}
return t.WriteData(data)
}
The reader automatically uses WriteDataWithOptions when available:
// Writing with force initialization
opts := nfc.TagWriteOptions{ForceInitialize: true}
if writer, ok := tag.(nfc.AdvancedWriter); ok {
err := writer.WriteDataWithOptions(data, opts)
} else {
// Fallback to standard write
err := tag.WriteData(data)
}
A manager whose devices connect over the network, rather than being attached to
this machine, owns that transport itself. remotenfc is the worked example: it
serves the WebSocket endpoint phones connect to.
Expose an http.Handler and let the agent mount it. Keep authentication out of
it, since that is the agent’s policy and not the driver’s, and take anything
else the agent configures as options:
func (m *MyManager) Handler(opts MyServerOptions) http.Handler {
// ...
}
For cleanup, implement Close(). MultiManager calls it on every manager that
has one:
func (m *MyManager) Close() {
// Stop goroutines, drop connections
}
Create mock implementations for testing:
func TestMyDevice(t *testing.T) {
device := &MyDevice{connection: "test"}
// Test capabilities
caps := device.Capabilities()
if !caps.CanPoll {
t.Error("Expected CanPoll to be true")
}
// Test GetTags
tags, err := device.GetTags()
if err != nil {
t.Errorf("GetTags failed: %v", err)
}
// Test tag capabilities
for _, tag := range tags {
tagCaps := nfc.GetTagCapabilities(tag)
if !tagCaps.CanRead {
t.Error("Expected tag to support reading")
}
}
}
A device that receives tag data over the network (read-only):
type NetworkTag struct {
nfc.BaseTag // write/transceive/lock default to "not supported"
uid string
tagType string
data []byte // Pre-loaded data
}
func (t *NetworkTag) UID() string { return t.uid }
func (t *NetworkTag) Type() string { return t.tagType }
func (t *NetworkTag) NumericType() int { return 0 }
func (t *NetworkTag) Capabilities() nfc.TagCapabilities {
return nfc.TagCapabilities{
CanRead: true,
CanWrite: false,
CanTransceive: false,
CanLock: false,
TagFamily: t.tagType,
}
}
func (t *NetworkTag) ReadData() ([]byte, error) {
return t.data, nil
}
// No WriteData needed; inherited from nfc.BaseTag as "not supported".
A device connected via serial port:
type PN532Device struct {
port io.ReadWriteCloser
conn string
}
func (d *PN532Device) DeviceType() string {
return "pn532-serial"
}
func (d *PN532Device) SupportedTagTypes() []string {
return []string{"MIFARE Classic", "NTAG", "ISO14443-4"}
}
func (d *PN532Device) GetTags() ([]nfc.Tag, error) {
// Send InListPassiveTarget command
cmd := []byte{0xD4, 0x4A, 0x01, 0x00}
resp, err := d.sendCommand(cmd)
if err != nil {
return nil, err
}
// Parse response and create tags
// ...
}
These four have no sensible default and must be implemented on every tag:
| Method | Interface |
|---|---|
UID() |
TagIdentifier |
Type() |
TagIdentifier |
NumericType() |
TagIdentifier |
ReadData() |
TagReader |
nfc.BaseTagEmbedding nfc.BaseTag supplies these with safe defaults. Override only the ones
your tag supports:
| Method | Interface | BaseTag default |
|---|---|---|
Connect() |
TagConnection | no-op (returns nil) |
Disconnect() |
TagConnection | no-op (returns nil) |
WriteData() |
TagWriter | returns NotSupported |
Transceive() |
TagTransceiver | returns NotSupported |
IsWritable() |
TagLocker | returns false |
CanMakeReadOnly() |
TagLocker | returns false |
MakeReadOnly() |
TagLocker | returns NotSupported |
| Method | Interface | Purpose |
|---|---|---|
Capabilities() |
TagCapabilityProvider | Runtime tag capability discovery |
WriteDataWithOptions() |
AdvancedWriter | Write with initialization options |
DeviceChanges() |
DeviceChangeNotifier | Device add/remove notifications |
DeviceType() |
DeviceInfoProvider | Device type identifier (“libnfc”, “smartphone”) |
SupportedTagTypes() |
DeviceInfoProvider | List of supported tag types |
SupportsEvents() |
DeviceEventEmitter | Whether device emits tag events |
SupportsTransceive() |
DeviceTransceiver | Whether device supports raw transceive |
IsHealthy() |
DeviceHealthChecker | Connection health validation |
Close() |
(bare method) | Cleanup on shutdown, called by MultiManager |
Implement this interface to provide device metadata. Capabilities are built automatically from this:
func (d *MyDevice) DeviceType() string {
return "myreader"
}
func (d *MyDevice) SupportedTagTypes() []string {
return []string{"MIFARE Classic", "NTAG"}
}
For event-based devices (like smartphones) that receive tags via events rather than polling:
func (d *MyDevice) SupportsEvents() bool {
return true // Tags arrive as events, not via polling
}
When SupportsEvents() returns true, BuildDeviceCapabilities() will automatically set:
CanPoll: falseSupportsEvents: trueCanTransceive is unaffected: how a device reports scans says nothing about
whether it can exchange bytes with the tag. Implement DeviceTransceiver to
declare that.
Devices default to CanTransceive: true. If your device’s Transceive actually
returns a NotSupported error, implement DeviceTransceiver so the reported
capabilities match reality:
func (d *MyDevice) SupportsTransceive() bool {
return false // Device cannot do raw transceive
}
When present, SupportsTransceive() is authoritative for CanTransceive in the
capabilities built by BuildDeviceCapabilities().
For devices that support connection health checking:
func (d *MyDevice) IsHealthy() error {
if !d.isConnected {
return fmt.Errorf("device not connected")
}
return nil
}
The DeviceManager uses this interface to check device health before operations.