NFC-DEP P2P Pairing on MediaTek Genio 720
mediatekgenionfcpn7160embedded linux

NFC-DEP P2P Pairing on MediaTek Genio 720

Aaron Angulo ·

NFC-DEP P2P pairing on MediaTek Genio 720 worked fine in our first bench test, sub-second, every time, then fell apart the moment we ran two boards side by side for more than a few minutes. Pairing would suddenly take five to fifteen minutes instead of under a second, recover on its own, then stall again, with nothing obviously wrong in the logs. Both boards ran the exact same PN7160 driver code, negotiating initiator and target roles over NCI 2.0 passively, which is what made the failure hard to see: there was nothing wrong with the protocol implementation. Two identical devices running synchronized timing windows were locking into the same phase instead of drifting apart, and that trap only shows up once you let two boards run long enough to find each other.

Key Insights

  • Symmetric NFC-DEP polling code on two identical boards doesn’t reliably diverge into initiator and target roles on its own. Software-managed LISTEN/POLL windows can resynchronize instead of complementing.
  • The failure mode: both boards spend most of their time in the same RF phase, both listening (no field from either side) or both polling (RF collision), producing five to fifteen minute pairing delays mid-session.
  • The fix is a single RF_DISCOVER command covering both NFC_A_PASSIVE_LISTEN and NFC_A_PASSIVE_POLL, combined with randomizing the NCI TOTAL_DURATION parameter per pairing attempt (150 to 350 milliseconds).
  • Two boards with different randomized cycle rates accumulate phase drift at roughly 1 to 3 Hz, guaranteeing a complementary LISTEN/POLL overlap within about 400 milliseconds.
  • Role isn’t chosen by application code. It falls out of the RF_INTF_ACTIVATED_NTF payload: byte[3] tells you whether you were found (target) or found the other side (initiator).

Why NFC-DEP P2P Pairing on MediaTek Genio 720 Kept Stalling

NFC-DEP P2P in passive NFC-A mode at 106 kbps works like this: the initiator generates the 13.56 MHz RF field and sends ATR_REQ; the target does not generate a field, it modulates the initiator’s field and responds with ATR_RES. Both sides run the same code base and the same chip. Nothing pre-assigns a role, it comes out of whichever side happens to be generating the field when the other side shows up. The NCI 2.0 state machine that governs this is RFST_IDLE → RF_DISCOVER → RFST_DISCOVERY → RF_INTF_ACTIVATED_NTF → RFST_POLL_ACTIVE / RFST_LISTEN_ACTIVE, and there’s one setup step easy to get wrong before any of that matters: RF_SET_LISTEN_ROUTING has to be called once, from RFST_IDLE, to route the NFC-DEP protocol to the device host. Skip it and the chip silently discards incoming activations even while RF_DISCOVER is running, no error, just nothing happening.

Our first implementation handled role negotiation the way it reads most naturally in application code: alternate the board between listen-only and poll-only RF_DISCOVER commands in software, half-second to one-second windows, with a pair() timeout firing every 30 seconds that restarted the cycle with fresh random jitter if nothing had happened. On a single board, tested against a phone or a reference reader, this looked completely fine.

Put two of our own boards next to each other and let them run, and the failure showed up: because both boards were running identical timing code, when their 30-second timeouts fired at close to the same moment, they’d restart with new jitter but often land back on the same phase. Instead of one board polling while the other listens, both would spend long stretches doing the same thing at the same time. Both listening means no RF field from either side, so nothing to activate. Both polling means two fields colliding, so no clean activation either way. The result was pairing delays of five to fifteen minutes in the middle of a session that had been pairing in under a second minutes earlier.

The Phase-Lock Math: Why Two Boards Resynchronize Instead of Diverging

Once we stopped treating this as a protocol bug and started treating it as a timing problem, the fix fell out of the math. Two boards running fixed, identical discovery windows have a phase drift rate of zero relative to each other. If a restart happens to land them on the same phase, they stay there indefinitely, and the only thing that ever breaks the tie is the random jitter applied at each 30-second restart, which is exactly the intermittent “it recovers, then stalls again” pattern we were seeing.

The alternative is to make sure the two boards are never running the same cycle rate in the first place, so the phase between them is always moving. Randomizing the discovery timing per pairing attempt is enough:

MetricValue
Board A TOTAL_DURATION200 ms → 5 cycles/sec
Board B TOTAL_DURATION280 ms → 3.57 cycles/sec
Phase drift rate|5 − 3.57| = 1.43 cycles/sec
Time to full desynchronization1 / 1.43 ≈ 700 ms
Expected time to complementary overlap~350 ms

With each board picking its own TOTAL_DURATION at random for every pairing attempt, the two cycle rates are almost never equal, so the relative phase between them constantly advances instead of sitting still. Within roughly one drift period, well under half a second in practice, one board is guaranteed to be polling while the other is listening. That complementary overlap is the only thing NFC-DEP needs in order to activate.

The Fix: One RF_DISCOVER Call and a Randomized TOTAL_DURATION

The correct approach doesn’t alternate anything in application code at all. Send a single RF_DISCOVER command that requests both NFC_A_PASSIVE_LISTEN and NFC_A_PASSIVE_POLL in the same call. The NFCC itself then cycles between the two technologies internally, at TOTAL_DURATION intervals, and that’s the one parameter we randomize per pair() call:

# _configure_dep() -- called once per pair() call
duration_ms = random.randint(150, 350)
self._proto.send_command(nci.GID_CORE, nci.OID_CORE_SET_CONFIG, bytes([
    2,
    0x00, 2, duration_ms & 0xFF, (duration_ms >> 8) & 0xFF,  # TOTAL_DURATION
    0x32, 1, 0x40,  # LA_SEL_INFO = 0x40 (NFC-DEP capable SAK)
]))

# RF_DISCOVER_MAP: map NFC-DEP for both poll and listen
self._proto.send_command(
    nci.GID_RF, nci.OID_RF_DISCOVER_MAP,
    bytes([1, nci.PROTOCOL_NFC_DEP, 0x03, nci.INTF_NFC_DEP]),
)

# RF_DISCOVER: both technologies in one command
self._proto.send_command(
    nci.GID_RF, nci.OID_RF_DISCOVER,
    bytes([2,
           nci.NFC_A_PASSIVE_LISTEN, 0x01,
           nci.NFC_A_PASSIVE_POLL,   0x01]),
)

Role determination then comes straight out of the activation notification, not from anything the application decided in advance:

if payload[3] == nci.NFC_A_PASSIVE_LISTEN:  # 0x80
    role = 'target'    # we were found by an initiator
else:                  # 0x00 = NFC_A_PASSIVE_POLL
    role = 'initiator' # we found a target

Getting this loop to run reliably in practice meant closing a handful of other NCI 2.0 gaps at the same time, all of them the kind of thing that only shows up once you’re running the fix continuously across many pairing cycles instead of once on a bench:

  1. RF_SET_LISTEN_ROUTING must be called from RFST_IDLE. Send it while the chip is in RFST_DISCOVERY and it returns SEMANTIC_ERROR. We now always issue RF_DEACTIVATE(DEACT_IDLE) before re-calling routing setup, instead of assuming we know the chip’s current state.
  2. RF_DEACTIVATE has an NTF guard. When the RF_DEACTIVATE response status isn’t STATUS_OK, meaning the chip was already idle, no RF_DEACTIVATE_NTF notification is coming. Waiting for it anyway burns the full two-second recv_ntf timeout on every single deactivation. Check the response status first, and only wait for the notification if the chip was active to begin with.
  3. RF_DEACTIVATE_NTF can arrive mid data exchange. If the RF link drops while data is in flight, because the peer moved away, the NFCC sends RF_DEACTIVATE_NTF. If recv_data doesn’t check for it explicitly, it silently consumes the notification and then waits the full five-second data timeout for a packet that’s never coming. One board reports a successful pairing while the other one just stalls. The fix is to fast-fail in recv_data the moment that notification shows up.
  4. The _running flag needs an unconditional deactivate guard. A previously swallowed exception can leave _running set to False while the chip is still sitting in RFST_DISCOVERY. The next pair() call then skips deactivation entirely, jumps straight to _configure_dep(), and hits SEMANTIC_ERROR on RF_SET_LISTEN_ROUTING for reasons that look unrelated to the actual cause. We now send RF_DEACTIVATE unconditionally every time and let the chip’s own status response tell us whether it was already idle.

Shipping the Fix Without Losing It on Reboot

The driver runs inside a Docker container on the target hardware, which surfaced a second, much more mundane problem once the fix was ready to deploy: copying updated files into a running container does not survive a full reboot. docker compose up -d recreates the container from its base image on every restart, wiping out anything that was copied in after the fact.

The pattern that survives reboots is to bind-mount the driver source from a host path into the container, read-only, and add a systemd drop-in on the service that manages the pairing daemon, with an ExecStartPre step that copies the updated driver files into the container’s Python virtual environment before the application process starts:

# /etc/systemd/system/nfc-pairing-app.service.d/pn7160-overlay.conf
[Service]
ExecStartPre=/usr/bin/docker exec nfc-app /bin/sh -c \
  'cp /opt/nfc-driver/pn7160/*.py /opt/venv/lib/python3.12/site-packages/pn7160/'

That survives a full reboot cleanly. Only a docker compose down followed by a fresh image reload from a tarball wipes it, which is rare enough in practice to treat as a known exception rather than something to build around.

If you’re bringing up the I2C bus the PN7160 sits on, our walkthrough on SPI and I2C peripheral setup on MediaTek Genio covers bus numbering and the device tree conflicts that show up before you ever get to the NFC layer. And if your NFC stack needs to live inside a container long-term rather than as a one-off test, see Docker with GPU acceleration on MediaTek Genio for the broader container deployment pattern this fix builds on.

Frequently Asked Questions

What causes NFC-DEP P2P pairing to fail or stall intermittently?

The most common cause on custom NFC-DEP implementations is a timing collision between the two devices’ discovery windows, not a protocol bug. If both sides alternate between listen-only and poll-only discovery on independent software timers, they can resynchronize onto the same phase instead of diverging into complementary initiator and target roles, producing multi-minute pairing delays that come and go without a clear pattern.

What is a phase-lock trap in NFC role negotiation?

It’s a failure mode where two devices running identical, synchronized timing code lock into the same discovery phase instead of drifting apart. Both boards end up listening at the same time (no RF field, nothing to activate) or polling at the same time (RF collision), so the pairing handshake that’s supposed to take under a second instead takes minutes.

How do you set TOTAL_DURATION in NCI 2.0 for NFC-DEP pairing?

TOTAL_DURATION is set via CORE_SET_CONFIG (config parameter 0x00, 2 bytes, little-endian) before RF_DISCOVER. It controls how long the NFCC spends cycling through each requested RF technology; with both listen and poll requested in one RF_DISCOVER call, each gets TOTAL_DURATION divided by two. Randomizing the value per pairing attempt, for example 150 to 350 milliseconds, is what prevents two boards from settling into a fixed, matching cycle.

Why does RF_SET_LISTEN_ROUTING return SEMANTIC_ERROR on the PN7160?

RF_SET_LISTEN_ROUTING can only be called while the chip is in RFST_IDLE. If it’s sent while the chip is already in RFST_DISCOVERY, the PN7160 returns SEMANTIC_ERROR and rejects the routing update. The fix is to always issue RF_DEACTIVATE with DEACT_IDLE before re-sending the routing configuration, rather than assuming the chip’s current state.

Does MediaTek Genio 720 support NFC-DEP peer-to-peer pairing?

Yes. We validated NFC-DEP P2P pairing on two Genio 720 (MT8390) boards using a PN7160 NFC controller over I2C, driven from a Python NCI 2.0 driver running in a Docker container. Sub-second pairing is achievable once the discovery timing is handled correctly at the RF_DISCOVER level rather than alternated in application code.


ProventusNova helps hardware startups solve embedded systems problems fast. Get expert MediaTek Genio support.

MediaTek Genio Expert Support

Building on MediaTek Genio?

BSP bring-up, GStreamer pipelines, NeuroPilot integration, we've shipped it. Get unblocked fast. One call to scope it, fixed bid to deliver it.

Frequently Asked Questions

What causes NFC-DEP P2P pairing to fail or stall intermittently?

The most common cause on custom NFC-DEP implementations is a timing collision between the two devices' discovery windows, not a protocol bug. If both sides alternate between listen-only and poll-only discovery on independent software timers, they can resynchronize onto the same phase instead of diverging into complementary initiator and target roles, producing multi-minute pairing delays that come and go without a clear pattern.

What is a phase-lock trap in NFC role negotiation?

It's a failure mode where two devices running identical, synchronized timing code lock into the same discovery phase instead of drifting apart. Both boards end up listening at the same time (no RF field, nothing to activate) or polling at the same time (RF collision), so the pairing handshake that's supposed to take under a second instead takes minutes.

How do you set TOTAL_DURATION in NCI 2.0 for NFC-DEP pairing?

TOTAL_DURATION is set via CORE_SET_CONFIG (config parameter 0x00, 2 bytes, little-endian) before RF_DISCOVER. It controls how long the NFCC spends cycling through each requested RF technology; with both listen and poll requested in one RF_DISCOVER call, each gets TOTAL_DURATION divided by two. Randomizing the value per pairing attempt, for example 150 to 350 milliseconds, is what prevents two boards from settling into a fixed, matching cycle.

Why does RF_SET_LISTEN_ROUTING return SEMANTIC_ERROR on the PN7160?

RF_SET_LISTEN_ROUTING can only be called while the chip is in RFST_IDLE. If it's sent while the chip is already in RFST_DISCOVERY, the PN7160 returns SEMANTIC_ERROR and rejects the routing update. The fix is to always issue RF_DEACTIVATE with DEACT_IDLE before re-sending the routing configuration, rather than assuming the chip's current state.

Does MediaTek Genio 720 support NFC-DEP peer-to-peer pairing?

Yes. We validated NFC-DEP P2P pairing on two Genio 720 (MT8390) boards using a PN7160 NFC controller over I2C, driven from a Python NCI 2.0 driver running in a Docker container. Sub-second pairing is achievable once the discovery timing is handled correctly at the RF_DISCOVER level rather than alternated in application code.

Aarón Angulo, Co-Founder & CEO at ProventusNova

Written by

Aarón Angulo

Co-Founder & CEO · ProventusNova

Obsessed with client outcomes. Aarón ensures every engagement delivers real results, on time, on scope, no exceptions.

Connect on LinkedIn