Skip to content

Commit 18e1fb7

Browse files
codebutlerclaude
andauthored
Add Sony RC-S956 support and multi-device PN53x discovery (#224)
* Add Sony RC-S956 support and multi-device PN53x discovery Handle PN53x error frames (0x7F) in transport layer by throwing PN533CommandException, enabling graceful fallback when SAMConfiguration is unsupported. RC-S956 devices (Sony RC-S370/P) use an alternative init sequence: resetMode + setParameters + RF configuration. Add openAll() to PN533Device for multi-device discovery, and wire dynamic backend creation in DesktopCardScanner so all connected PN53x readers scan independently. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Remove resetMode from RC-S956 init, guard writeRegister resetMode (cmd 0x18) returns error frame 0x7F on the RC-S370/P — the device is already in reader mode over USB so it's not needed. writeRegister (cmd 0x08) may also be unsupported on some firmware versions, so wrap it in try/catch. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Make RC-S956 init resilient: try each command individually Wrap every init command in tryCommand() so unsupported commands are logged and skipped rather than crashing the backend. This lets us see exactly which commands the RC-S956 accepts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Match nfcpy rcs956.py init sequence exactly Key fixes based on reading nfcpy source: - resetMode() must send ACK frame + 10ms delay after command - Never try SAMConfiguration on RC-S956 (corrupts state machine) - Detect RC-S956 from firmware version (version < 2) - Use nfcpy's exact init order: resetMode, rfFieldOff, RF config, setParameters, resetMode again, writeRegister - Fix 106A RF config first byte: 0x5A not 0x59 - Use nfcpy's MaxRetries values: [0x00, 0x00, 0x01] Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Separate RC-S956 into its own backend class Extract shared poll/read logic into abstract PN53xReaderBackend. PN533ReaderBackend now only contains the SAM-based init (untouched from the PN533 PR). RCS956ReaderBackend has the nfcpy-based init sequence in complete isolation. DesktopCardScanner probes firmware version to create the right backend type for each discovered transport. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix RC-S956 mute: use auto RFCA (0x02) not RF off (0x00) nfcpy's mute() sends rf_configuration(0x01, [0x02]) which enables auto RF collision avoidance — the field activates on demand when InListPassiveTarget polls. Our rfFieldOff() was sending 0x00 which fully disables the field, causing InListPassiveTarget to return error frame 0x7F. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Match nfcpy init exactly: initial ACK + 3 resetMode calls nfcpy init(transport) sends ACK as the very first action before any commands. Device.__init__ then does: resetMode #1 → getFirmwareVersion → mute (resetMode #2 + auto RFCA) → RF config → setParameters → resetMode #3 → writeRegister Previously we were probing firmware before sending ACK, and only had 2 resetMode calls instead of 3 (mute calls resetMode + rfConfig, not just rfConfig). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix FeliCa polling on RC-S956: pass SENSF_REQ initiator data RC-S956 requires explicit SENSF_REQ initiator data (00 FF FF 01 00) for FeliCa InListPassiveTarget, unlike PN533 which generates defaults internally. Without it, RC-S956 returns error frame 0x7F. - Pass SENSF_REQ in both pollLoop and waitForRemoval FeliCa polls - Catch PN533CommandException in inListPassiveTarget as fallback - Add wire-level debug logging (disabled by default) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Use InCommunicateThru for RC-S956 ISO-DEP data exchange RC-S956 firmware does not support InDataExchange (0x40), returning error frame 0x7F. nfcpy uses InCommunicateThru (0x42) for all target communication on PN53x devices. Add PN533CommunicateThruTransceiver that wraps APDUs in ISO-DEP I-blocks (alternating block numbers) and handles S(WTX) waiting time extensions. RC-S956 backend overrides createTransceiver to use it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d1b6c42 commit 18e1fb7

File tree

8 files changed

+505
-196
lines changed

8 files changed

+505
-196
lines changed

app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/DesktopCardScanner.kt

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
package com.codebutler.farebot.desktop
2424

2525
import com.codebutler.farebot.card.RawCard
26+
import com.codebutler.farebot.card.nfc.pn533.PN533
27+
import com.codebutler.farebot.card.nfc.pn533.PN533Device
2628
import com.codebutler.farebot.shared.nfc.CardScanner
2729
import com.codebutler.farebot.shared.nfc.ScannedTag
2830
import kotlinx.coroutines.CoroutineScope
@@ -63,18 +65,13 @@ class DesktopCardScanner : CardScanner {
6365
private var scanJob: Job? = null
6466
private val scope = CoroutineScope(Dispatchers.IO)
6567

66-
private val backends: List<NfcReaderBackend> =
67-
listOf(
68-
PcscReaderBackend(),
69-
PN533ReaderBackend(),
70-
)
71-
7268
override fun startActiveScan() {
7369
if (scanJob?.isActive == true) return
7470
_isScanning.value = true
7571

7672
scanJob =
7773
scope.launch {
74+
val backends = discoverBackends()
7875
val backendJobs =
7976
backends.map { backend ->
8077
launch {
@@ -116,5 +113,29 @@ class DesktopCardScanner : CardScanner {
116113
scanJob?.cancel()
117114
scanJob = null
118115
_isScanning.value = false
116+
PN533Device.shutdown()
117+
}
118+
119+
private fun discoverBackends(): List<NfcReaderBackend> {
120+
val backends = mutableListOf<NfcReaderBackend>(PcscReaderBackend())
121+
val transports = PN533Device.openAll()
122+
if (transports.isEmpty()) {
123+
backends.add(PN533ReaderBackend())
124+
} else {
125+
transports.forEachIndexed { index, transport ->
126+
transport.flush()
127+
transport.sendAck() // RC-S956 needs ACK before first command
128+
val probe = PN533(transport)
129+
val fw = probe.getFirmwareVersion()
130+
val label = "PN53x #${index + 1}"
131+
println("[DesktopCardScanner] $label firmware: $fw")
132+
if (fw.version >= 2) {
133+
backends.add(PN533ReaderBackend(transport))
134+
} else {
135+
backends.add(RCS956ReaderBackend(transport, label))
136+
}
137+
}
138+
}
139+
return backends
119140
}
120141
}

app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/PN533ReaderBackend.kt

Lines changed: 8 additions & 181 deletions
Original file line numberDiff line numberDiff line change
@@ -22,194 +22,21 @@
2222

2323
package com.codebutler.farebot.desktop
2424

25-
import com.codebutler.farebot.card.CardType
26-
import com.codebutler.farebot.card.RawCard
27-
import com.codebutler.farebot.card.cepas.CEPASCardReader
28-
import com.codebutler.farebot.card.classic.ClassicCardReader
29-
import com.codebutler.farebot.card.felica.FeliCaReader
30-
import com.codebutler.farebot.card.felica.PN533FeliCaTagAdapter
3125
import com.codebutler.farebot.card.nfc.pn533.PN533
32-
import com.codebutler.farebot.card.nfc.pn533.PN533CardInfo
33-
import com.codebutler.farebot.card.nfc.pn533.PN533CardTransceiver
34-
import com.codebutler.farebot.card.nfc.pn533.PN533ClassicTechnology
35-
import com.codebutler.farebot.card.nfc.pn533.PN533Device
36-
import com.codebutler.farebot.card.nfc.pn533.PN533Exception
37-
import com.codebutler.farebot.card.nfc.pn533.PN533UltralightTechnology
38-
import com.codebutler.farebot.card.ultralight.UltralightCardReader
39-
import com.codebutler.farebot.shared.nfc.ISO7816Dispatcher
40-
import com.codebutler.farebot.shared.nfc.ScannedTag
26+
import com.codebutler.farebot.card.nfc.pn533.PN533Transport
4127

4228
/**
43-
* PN533 raw USB reader backend.
44-
*
45-
* Communicates with PN533-based NFC readers (e.g., SCM SCL3711)
46-
* directly over USB bulk transfers, bypassing the PC/SC subsystem.
29+
* NXP PN533 reader backend (e.g., SCM SCL3711).
4730
*/
48-
class PN533ReaderBackend : NfcReaderBackend {
31+
class PN533ReaderBackend(
32+
transport: PN533Transport? = null,
33+
) : PN53xReaderBackend(transport) {
4934
override val name: String = "PN533"
5035

51-
override fun scanLoop(
52-
onCardDetected: (ScannedTag) -> Unit,
53-
onCardRead: (RawCard<*>) -> Unit,
54-
onError: (Throwable) -> Unit,
55-
) {
56-
val transport =
57-
PN533Device.open()
58-
?: throw Exception("PN533 device not found")
59-
60-
transport.flush()
61-
val pn533 = PN533(transport)
62-
try {
63-
initDevice(pn533)
64-
pollLoop(pn533, onCardDetected, onCardRead, onError)
65-
} finally {
66-
pn533.close()
67-
}
68-
}
69-
70-
private fun initDevice(pn533: PN533) {
36+
override fun initDevice(pn533: PN533) {
7137
val fw = pn533.getFirmwareVersion()
72-
println("[PN533] Firmware: $fw")
73-
38+
println("[$name] Firmware: $fw")
7439
pn533.samConfiguration()
75-
pn533.setMaxRetries()
76-
}
77-
78-
private fun pollLoop(
79-
pn533: PN533,
80-
onCardDetected: (ScannedTag) -> Unit,
81-
onCardRead: (RawCard<*>) -> Unit,
82-
onError: (Throwable) -> Unit,
83-
) {
84-
while (true) {
85-
println("[PN533] Polling for cards...")
86-
87-
// Try ISO 14443-A (106 kbps) first — covers Classic, Ultralight, DESFire
88-
var target = pn533.inListPassiveTarget(baudRate = PN533.BAUD_RATE_106_ISO14443A)
89-
90-
// Try FeliCa (212 kbps) if no Type A card found
91-
if (target == null) {
92-
target = pn533.inListPassiveTarget(baudRate = PN533.BAUD_RATE_212_FELICA)
93-
}
94-
95-
if (target == null) {
96-
Thread.sleep(POLL_INTERVAL_MS)
97-
continue
98-
}
99-
100-
val tagId =
101-
when (target) {
102-
is PN533.TargetInfo.TypeA -> target.uid
103-
is PN533.TargetInfo.FeliCa -> target.idm
104-
}
105-
val cardTypeName =
106-
when (target) {
107-
is PN533.TargetInfo.TypeA -> PN533CardInfo.fromTypeA(target).cardType.name
108-
is PN533.TargetInfo.FeliCa -> CardType.FeliCa.name
109-
}
110-
onCardDetected(ScannedTag(id = tagId, techList = listOf(cardTypeName)))
111-
112-
try {
113-
val rawCard = readTarget(pn533, target)
114-
onCardRead(rawCard)
115-
println("[PN533] Card read successfully")
116-
} catch (e: Exception) {
117-
println("[PN533] Read error: ${e.message}")
118-
onError(e)
119-
}
120-
121-
// Release target
122-
try {
123-
pn533.inRelease(target.tg)
124-
} catch (_: PN533Exception) {
125-
}
126-
127-
// Wait for card removal by polling until no target detected
128-
println("[PN533] Waiting for card removal...")
129-
waitForRemoval(pn533)
130-
}
131-
}
132-
133-
private fun readTarget(
134-
pn533: PN533,
135-
target: PN533.TargetInfo,
136-
): RawCard<*> =
137-
when (target) {
138-
is PN533.TargetInfo.TypeA -> readTypeACard(pn533, target)
139-
is PN533.TargetInfo.FeliCa -> readFeliCaCard(pn533, target)
140-
}
141-
142-
private fun readTypeACard(
143-
pn533: PN533,
144-
target: PN533.TargetInfo.TypeA,
145-
): RawCard<*> {
146-
val info = PN533CardInfo.fromTypeA(target)
147-
val tagId = target.uid
148-
println("[PN533] Type A card: type=${info.cardType}, SAK=0x%02X, UID=${tagId.hex()}".format(target.sak))
149-
150-
return when (info.cardType) {
151-
CardType.MifareDesfire, CardType.ISO7816 -> {
152-
val transceiver = PN533CardTransceiver(pn533, target.tg)
153-
ISO7816Dispatcher.readCard(tagId, transceiver)
154-
}
155-
156-
CardType.MifareClassic -> {
157-
val tech = PN533ClassicTechnology(pn533, target.tg, tagId, info)
158-
ClassicCardReader.readCard(tagId, tech, null)
159-
}
160-
161-
CardType.MifareUltralight -> {
162-
val tech = PN533UltralightTechnology(pn533, target.tg, info)
163-
UltralightCardReader.readCard(tagId, tech)
164-
}
165-
166-
CardType.CEPAS -> {
167-
val transceiver = PN533CardTransceiver(pn533, target.tg)
168-
CEPASCardReader.readCard(tagId, transceiver)
169-
}
170-
171-
else -> {
172-
val transceiver = PN533CardTransceiver(pn533, target.tg)
173-
ISO7816Dispatcher.readCard(tagId, transceiver)
174-
}
175-
}
176-
}
177-
178-
private fun readFeliCaCard(
179-
pn533: PN533,
180-
target: PN533.TargetInfo.FeliCa,
181-
): RawCard<*> {
182-
val tagId = target.idm
183-
println("[PN533] FeliCa card: IDm=${tagId.hex()}")
184-
val adapter = PN533FeliCaTagAdapter(pn533, target.idm)
185-
return FeliCaReader.readTag(tagId, adapter)
186-
}
187-
188-
private fun waitForRemoval(pn533: PN533) {
189-
while (true) {
190-
Thread.sleep(REMOVAL_POLL_INTERVAL_MS)
191-
val target =
192-
try {
193-
pn533.inListPassiveTarget(baudRate = PN533.BAUD_RATE_106_ISO14443A)
194-
?: pn533.inListPassiveTarget(baudRate = PN533.BAUD_RATE_212_FELICA)
195-
} catch (_: PN533Exception) {
196-
null
197-
}
198-
if (target == null) {
199-
break
200-
}
201-
// Card still present, release and keep waiting
202-
try {
203-
pn533.inRelease(target.tg)
204-
} catch (_: PN533Exception) {
205-
}
206-
}
207-
}
208-
209-
companion object {
210-
private const val POLL_INTERVAL_MS = 250L
211-
private const val REMOVAL_POLL_INTERVAL_MS = 300L
212-
213-
private fun ByteArray.hex(): String = joinToString("") { "%02X".format(it) }
40+
pn533.setMaxRetries(passiveActivation = 0x02)
21441
}
21542
}

0 commit comments

Comments
 (0)