From fb04abafaa977bfe3ba92af5179170fdc02fd432 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Fortune Date: Tue, 28 Apr 2026 14:43:15 +0200 Subject: [PATCH 1/6] feat: add INNOVATRON_B_PRIME protocol handling and improve card disconnection logic --- build.gradle.kts | 2 +- gradle.properties | 2 +- .../keyple/plugin/pcsc/PcscReaderAdapter.java | 57 ++++++++++++++----- 3 files changed, 46 insertions(+), 15 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 55084bb..2ac0624 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,7 +16,7 @@ plugins { dependencies { implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar")))) implementation("org.eclipse.keyple:keyple-common-java-api:2.0.2") - implementation("org.eclipse.keyple:keyple-plugin-java-api:2.3.2") + implementation("org.eclipse.keyple:keyple-plugin-java-api:3.0.0-SNAPSHOT") { isChanging = true } implementation("org.eclipse.keyple:keyple-util-java-lib:2.4.1") implementation("net.java.dev.jna:jna:5.15.0") compileOnly("org.slf4j:slf4j-api:1.7.36") diff --git a/gradle.properties b/gradle.properties index c300172..2e009a9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ group = org.eclipse.keyple title = Keyple Plugin PCSC Java Lib description = Keyple add-on to manage PC/SC readers -version = 2.6.3-SNAPSHOT +version = 3.0.0-SNAPSHOT # Java Configuration javaSourceLevel = 1.8 diff --git a/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReaderAdapter.java b/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReaderAdapter.java index 832ef69..f44813d 100644 --- a/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReaderAdapter.java +++ b/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReaderAdapter.java @@ -60,6 +60,7 @@ final class PcscReaderAdapter private final AtomicBoolean loopWaitCardRemoval = new AtomicBoolean(); private boolean isObservationActive; + private boolean isProtocolInnovatronBPrime; /** * Constructor. @@ -234,6 +235,8 @@ public boolean isCurrentProtocol(String readerProtocol) { if (protocolRule != null && !protocolRule.isEmpty()) { String atr = HexUtil.toHex(card.getATR().getBytes()); isCurrentProtocol = Pattern.compile(protocolRule).matcher(atr).matches(); + isProtocolInnovatronBPrime = + readerProtocol.equals(PcscCardCommunicationProtocol.INNOVATRON_B_PRIME.name()); } else { isCurrentProtocol = false; } @@ -260,6 +263,31 @@ public void onStopDetection() { isObservationActive = false; } + /** + * {@inheritDoc} + * + * @since 3.0.0 + */ + @Override + public void deselectCard() { + try { + if (card != null) { + if (card instanceof Smartcardio.JnaCard) { + // disconnect using the extended mode allowing UNPOWER + ((Smartcardio.JnaCard) card).disconnect(getDisposition(DisconnectionMode.UNPOWER)); + // reset the reader state to avoid bad card detection next time + communicationTerminal.connect("*").disconnect(false); + } else { + card.disconnect(true); + } + } + } catch (CardException e) { + logger.warn("Failed to close the physical channel. Reader: " + name, e); + } finally { + resetContext(); + } + } + /** * {@inheritDoc} * @@ -312,8 +340,7 @@ public void openPhysicalChannel() throws ReaderIOException, CardIOException { */ @Override public void closePhysicalChannel() throws ReaderIOException { - // If the reader is observed, the actual disconnection will be done in the card removal sequence - if (!isObservationActive) { + if (!isProtocolInnovatronBPrime || !isObservationActive) { disconnect(); } } @@ -340,9 +367,7 @@ private void disconnect() throws ReaderIOException { try { if (card != null) { DisconnectionMode effectiveMode = - isCurrentProtocol(PcscCardCommunicationProtocol.INNOVATRON_B_PRIME.name()) - ? DisconnectionMode.UNPOWER - : disconnectionMode; + isProtocolInnovatronBPrime ? DisconnectionMode.UNPOWER : disconnectionMode; if (card instanceof Smartcardio.JnaCard) { // disconnect using the extended mode allowing UNPOWER ((Smartcardio.JnaCard) card).disconnect(getDisposition(effectiveMode)); @@ -424,7 +449,9 @@ public boolean isPhysicalChannelOpen() { public boolean checkCardPresence() throws ReaderIOException { try { boolean isCardPresent = communicationTerminal.isCardPresent(); - closePhysicalChannelSafely(); + if (!isCardPresent && card != null) { + closePhysicalChannelSafely(); + } return isCardPresent; } catch (CardException e) { throw new ReaderIOException("Failed to check card presence. Reader: " + name, e); @@ -451,6 +478,9 @@ private void resetContext() { */ @Override public String getPowerOnData() { + if (card == null) { + return ""; + } return HexUtil.toHex(card.getATR().getBytes()); } @@ -525,8 +555,7 @@ public void onUnregister() { @Override public void monitorCardPresenceDuringProcessing() throws ReaderIOException, TaskCanceledException { - doWaitForCardRemoval( - !isCurrentProtocol(PcscCardCommunicationProtocol.INNOVATRON_B_PRIME.name())); + doWaitForCardRemoval(!isProtocolInnovatronBPrime); } /** @@ -556,13 +585,13 @@ private void doWaitForCardRemoval(boolean allowPolling) } loopWaitCardRemoval.set(true); try { - if (allowPolling && disconnectionMode == DisconnectionMode.UNPOWER) { + if (allowPolling && isProtocolInnovatronBPrime) { waitForCardRemovalByPolling(); } else { waitForCardRemovalStandard(); } } finally { - if (loopWaitCardRemoval.get()) { + if (loopWaitCardRemoval.get() && isProtocolInnovatronBPrime) { try { disconnect(); } catch (Exception e) { @@ -589,16 +618,18 @@ private void doWaitForCardRemoval(boolean allowPolling) private void waitForCardRemovalByPolling() { try { while (loopWaitCardRemoval.get()) { - transmitApdu(pingApdu); + if (!checkCardPresence()) { + return; + } Thread.sleep(25); if (Thread.interrupted()) { return; } } - } catch (CardIOException | ReaderIOException e) { + } catch (ReaderIOException e) { if (logger.isTraceEnabled()) { logger.trace( - "[readerExt={}] Expected IOException received while waiting for card removal [reason={}]", + "[readerExt={}] ReaderIOException received while waiting for card removal [reason={}]", getName(), e.getMessage()); } From f0b18127fe104c9934388d0898ad1a4a6201be95 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Fortune Date: Tue, 28 Apr 2026 17:30:50 +0200 Subject: [PATCH 2/6] refactor: optimize physical channel management and streamline card disconnection handling --- .../keyple/plugin/pcsc/PcscReader.java | 25 +- .../keyple/plugin/pcsc/PcscReaderAdapter.java | 220 ++++++++---------- 2 files changed, 114 insertions(+), 131 deletions(-) diff --git a/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReader.java b/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReader.java index d87d587..b5b9b4e 100644 --- a/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReader.java +++ b/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReader.java @@ -119,15 +119,6 @@ enum DisconnectionMode { */ RESET, - /** - * Leaves the card in its current state without performing any reset or power down. - * - *

Corresponds to PC/SC `SCARD_LEAVE_CARD`. - * - * @since 2.0.0 - */ - LEAVE, - /** * Completely powers off the card. * @@ -210,14 +201,22 @@ enum DisconnectionMode { PcscReader setIsoProtocol(IsoProtocol isoProtocol); /** - * Changes the action to be taken after disconnection (default value {@link - * DisconnectionMode#RESET}). + * Changes the action to be taken when {@link + * org.eclipse.keyple.core.plugin.spi.reader.ReaderSpi#closePhysicalChannel()} is called (default + * value {@link DisconnectionMode#RESET}). + * + *

This setting applies to the forced-close path (e.g. non-observable mode, or abnormal + * termination). In observable mode, the channel is closed by {@code deselectCard()} (always + * SCARD_UNPOWER_CARD) before card-removal detection, so {@code closePhysicalChannel()} is + * typically a no-op and this setting has no effect. * - *

The card is either reset or left as is. + *

{@link DisconnectionMode#UNPOWER} and {@link DisconnectionMode#EJECT} require the default + * jnasmartcardio provider; they silently fall back to {@link DisconnectionMode#RESET} with other + * providers. * * @param disconnectionMode The {@link DisconnectionMode} to use (must be not null). * @return This instance. - * @throws IllegalArgumentException If disconnectionMode is null + * @throws IllegalArgumentException If disconnectionMode is null. * @since 2.0.0 */ PcscReader setDisconnectionMode(DisconnectionMode disconnectionMode); diff --git a/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReaderAdapter.java b/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReaderAdapter.java index f44813d..8103cba 100644 --- a/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReaderAdapter.java +++ b/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReaderAdapter.java @@ -49,18 +49,19 @@ final class PcscReaderAdapter private final PcscPluginAdapter pluginAdapter; private final boolean isWindows; private final int cardMonitoringCycleDuration; - private final byte[] pingApdu = HexUtil.toByteArray("00C0000000"); // GET RESPONSE private Card card; private CardChannel channel; private Boolean isContactless; private String protocol = IsoProtocol.ANY.getValue(); private boolean isModeExclusive = false; private DisconnectionMode disconnectionMode = DisconnectionMode.RESET; - private final AtomicBoolean loopWaitCard = new AtomicBoolean(); + private boolean physicalChannelOpen = false; + private byte[] cachedPowerOnData = null; + private final AtomicBoolean loopWaitCard = new AtomicBoolean(); private final AtomicBoolean loopWaitCardRemoval = new AtomicBoolean(); private boolean isObservationActive; - private boolean isProtocolInnovatronBPrime; + private boolean isProtocolInnovatronBPrime = false; /** * Constructor. @@ -233,7 +234,7 @@ public boolean isCurrentProtocol(String readerProtocol) { String protocolRule = pluginAdapter.getProtocolRule(readerProtocol); boolean isCurrentProtocol; if (protocolRule != null && !protocolRule.isEmpty()) { - String atr = HexUtil.toHex(card.getATR().getBytes()); + String atr = cachedPowerOnData != null ? HexUtil.toHex(cachedPowerOnData) : ""; isCurrentProtocol = Pattern.compile(protocolRule).matcher(atr).matches(); isProtocolInnovatronBPrime = readerProtocol.equals(PcscCardCommunicationProtocol.INNOVATRON_B_PRIME.name()); @@ -266,25 +267,40 @@ public void onStopDetection() { /** * {@inheritDoc} * + *

Sends S(DESELECT) to put the PICC in HALT state (via SCARD_UNPOWER_CARD). The ATR cache is + * preserved so the framework can still log it after deselection while the card remains physically + * present. + * * @since 3.0.0 */ @Override public void deselectCard() { + if (!physicalChannelOpen) { + return; + } try { - if (card != null) { - if (card instanceof Smartcardio.JnaCard) { - // disconnect using the extended mode allowing UNPOWER - ((Smartcardio.JnaCard) card).disconnect(getDisposition(DisconnectionMode.UNPOWER)); - // reset the reader state to avoid bad card detection next time + if (card instanceof Smartcardio.JnaCard) { + ((Smartcardio.JnaCard) card).disconnect(getDisposition(DisconnectionMode.UNPOWER)); + // reset the driver state to avoid stale reader state after UNPOWER on some drivers + try { communicationTerminal.connect("*").disconnect(false); - } else { - card.disconnect(true); + } catch (CardException ignored) { + // NOP } + } else { + card.disconnect(true); } } catch (CardException e) { - logger.warn("Failed to close the physical channel. Reader: " + name, e); + // Card already removed before deselect: treat silently (spec §4.3 pt 5) + if (logger.isDebugEnabled()) { + logger.debug( + "[readerExt={}] deselectCard: card already removed [reason={}]", name, e.getMessage()); + } } finally { - resetContext(); + // cachedPowerOnData is intentionally kept: card is physically present in HALT state + physicalChannelOpen = false; + card = null; + channel = null; } } @@ -301,20 +317,23 @@ public String getName() { /** * {@inheritDoc} * + *

No-op if the channel was already opened by {@link #checkCardPresence()} during + * anti-collision. + * * @since 2.0.0 */ @Override public void openPhysicalChannel() throws ReaderIOException, CardIOException { - if (card != null) { + if (physicalChannelOpen) { return; } - /* init of the card physical channel: if not yet established, opening of a new physical channel */ + isProtocolInnovatronBPrime = false; try { if (logger.isDebugEnabled()) { logger.debug( "[readerExt={}] Opening card physical channel [protocol={}]", getName(), protocol); } - card = this.communicationTerminal.connect(protocol); + card = communicationTerminal.connect(protocol); if (isModeExclusive) { card.beginExclusive(); if (logger.isDebugEnabled()) { @@ -326,6 +345,8 @@ public void openPhysicalChannel() throws ReaderIOException, CardIOException { } } channel = card.getBasicChannel(); + cachedPowerOnData = card.getATR().getBytes(); + physicalChannelOpen = true; } catch (CardNotPresentException e) { throw new CardIOException("Card removed. Reader: " + name, e); } catch (CardException e) { @@ -336,53 +357,39 @@ public void openPhysicalChannel() throws ReaderIOException, CardIOException { /** * {@inheritDoc} * + *

Forced close using the configured {@link DisconnectionMode}. No-op if the channel is already + * closed. For contactless readers, {@link #deselectCard()} (SCARD_UNPOWER_CARD) should be called + * first for a protocol-clean HALT transition; this method is then a no-op in normal flow. + * + *

UNPOWER and EJECT modes require jnasmartcardio; they silently fall back to RESET with other + * providers. + * * @since 2.0.0 */ @Override public void closePhysicalChannel() throws ReaderIOException { - if (!isProtocolInnovatronBPrime || !isObservationActive) { - disconnect(); + if (!physicalChannelOpen) { + return; } - } - - /** - * Disconnects the current card and resets the context and reader state. - * - *

This method handles the disconnection of a card, taking into account the specific - * disconnection mode. If the card uses the {@code INNOVATRON_B_PRIME} protocol, the disconnection - * mode is unconditionally overridden to {@link DisconnectionMode#UNPOWER}, regardless of the - * configured mode. If the card is an instance of {@link Smartcardio.JnaCard}, it disconnects - * using the extended mode specified by {@link #getDisposition(DisconnectionMode)} and resets the - * reader state to avoid incorrect card detection in subsequent operations. For other card types, - * it disconnects using the effective disconnection mode directly. - * - *

If a {@link CardException} occurs during the operation, a {@link ReaderIOException} is - * thrown with the associated error message. - * - *

Once the disconnection is handled, the method ensures that the context is reset. - * - * @throws ReaderIOException If an error occurs while closing the physical channel. - */ - private void disconnect() throws ReaderIOException { try { - if (card != null) { - DisconnectionMode effectiveMode = - isProtocolInnovatronBPrime ? DisconnectionMode.UNPOWER : disconnectionMode; - if (card instanceof Smartcardio.JnaCard) { - // disconnect using the extended mode allowing UNPOWER - ((Smartcardio.JnaCard) card).disconnect(getDisposition(effectiveMode)); - // reset the reader state to avoid bad card detection next time - resetReaderState(effectiveMode); - } else { - card.disconnect( - effectiveMode == DisconnectionMode.UNPOWER - || effectiveMode == DisconnectionMode.RESET); - } + if (card instanceof Smartcardio.JnaCard) { + ((Smartcardio.JnaCard) card).disconnect(getDisposition(disconnectionMode)); + } else { + // UNPOWER and EJECT are not available outside jnasmartcardio: fall back to RESET + card.disconnect(true); } } catch (CardException e) { - throw new ReaderIOException("Failed to close the physical channel. Reader: " + name, e); + String msg = e.getMessage() != null ? e.getMessage() : ""; + if (!msg.contains("SCARD_E_NO_SMARTCARD") + && !msg.contains("REMOVED") + && !msg.contains("NO_SMARTCARD")) { + throw new ReaderIOException("Failed to close the physical channel. Reader: " + name, e); + } } finally { - resetContext(); + physicalChannelOpen = false; + card = null; + channel = null; + cachedPowerOnData = null; } } @@ -396,8 +403,6 @@ private static int getDisposition(DisconnectionMode mode) { switch (mode) { case RESET: return Smartcardio.JnaCard.SCARD_RESET_CARD; - case LEAVE: - return Smartcardio.JnaCard.SCARD_LEAVE_CARD; case UNPOWER: return Smartcardio.JnaCard.SCARD_UNPOWER_CARD; case EJECT: @@ -407,29 +412,6 @@ private static int getDisposition(DisconnectionMode mode) { } } - /** - * Resets the state of the card reader. - * - *

This method attempts to reset the reader state based on the effective disconnection mode. If - * the effective mode is {@link DisconnectionMode#UNPOWER} (either configured or forced by the - * {@code INNOVATRON_B_PRIME} protocol), it reconnects to the terminal and then disconnects - * without powering off the reader. If any {@link CardException} occurs during this process, it is - * handled silently. - * - * @param effectiveMode The disconnection mode actually applied, which may differ from the - * configured {@link #disconnectionMode} when the card uses the {@code INNOVATRON_B_PRIME} - * protocol. - */ - private void resetReaderState(DisconnectionMode effectiveMode) { - try { - if (effectiveMode == DisconnectionMode.UNPOWER) { - communicationTerminal.connect("*").disconnect(false); - } - } catch (CardException e) { - // NOP - } - } - /** * {@inheritDoc} * @@ -437,40 +419,55 @@ private void resetReaderState(DisconnectionMode effectiveMode) { */ @Override public boolean isPhysicalChannelOpen() { - return card != null; + return physicalChannelOpen; } /** * {@inheritDoc} * + *

When the channel is closed (canal fermé), attempts a full {@code SCardConnect()} to perform + * anti-collision for contactless readers. On success the channel is marked open and a subsequent + * call to {@link #openPhysicalChannel()} is a no-op. When the channel is open (canal ouvert), + * checks physical presence via {@code SCardGetStatusChange} and calls {@link + * #closePhysicalChannel()} internally if the card is no longer present. + * * @since 2.0.0 */ @Override public boolean checkCardPresence() throws ReaderIOException { try { - boolean isCardPresent = communicationTerminal.isCardPresent(); - if (!isCardPresent && card != null) { - closePhysicalChannelSafely(); + if (!physicalChannelOpen) { + // Canal fermé: attempt connection (performs anti-collision for contactless readers) + try { + isProtocolInnovatronBPrime = false; + card = communicationTerminal.connect(protocol); + if (isModeExclusive) { + card.beginExclusive(); + } + channel = card.getBasicChannel(); + cachedPowerOnData = card.getATR().getBytes(); + physicalChannelOpen = true; + return true; + } catch (CardNotPresentException e) { + return false; + } + } else { + // Canal ouvert: verify card still responds + boolean isPresent = communicationTerminal.isCardPresent(); + if (!isPresent) { + try { + closePhysicalChannel(); + } catch (ReaderIOException ignored) { + // card already gone; flags are reset in closePhysicalChannel finally block + } + } + return isPresent; } - return isCardPresent; } catch (CardException e) { throw new ReaderIOException("Failed to check card presence. Reader: " + name, e); } } - private void closePhysicalChannelSafely() { - try { - disconnect(); - } catch (Exception e) { - // NOP - } - } - - private void resetContext() { - card = null; - channel = null; - } - /** * {@inheritDoc} * @@ -478,10 +475,10 @@ private void resetContext() { */ @Override public String getPowerOnData() { - if (card == null) { + if (cachedPowerOnData == null) { return ""; } - return HexUtil.toHex(card.getATR().getBytes()); + return HexUtil.toHex(cachedPowerOnData); } /** @@ -584,23 +581,10 @@ private void doWaitForCardRemoval(boolean allowPolling) logger.trace("[readerExt={}] Starting waiting card removal", name); } loopWaitCardRemoval.set(true); - try { - if (allowPolling && isProtocolInnovatronBPrime) { - waitForCardRemovalByPolling(); - } else { - waitForCardRemovalStandard(); - } - } finally { - if (loopWaitCardRemoval.get() && isProtocolInnovatronBPrime) { - try { - disconnect(); - } catch (Exception e) { - logger.warn( - "[readerExt={}] Failed to disconnect card during card removal sequence [reason={}]", - name, - e.getMessage()); - } - } + if (allowPolling && isProtocolInnovatronBPrime) { + waitForCardRemovalByPolling(); + } else { + waitForCardRemovalStandard(); } if (logger.isTraceEnabled()) { if (!loopWaitCardRemoval.get()) { @@ -618,7 +602,7 @@ private void doWaitForCardRemoval(boolean allowPolling) private void waitForCardRemovalByPolling() { try { while (loopWaitCardRemoval.get()) { - if (!checkCardPresence()) { + if (!monitoringTerminal.isCardPresent()) { return; } Thread.sleep(25); @@ -626,7 +610,7 @@ private void waitForCardRemovalByPolling() { return; } } - } catch (ReaderIOException e) { + } catch (CardException e) { if (logger.isTraceEnabled()) { logger.trace( "[readerExt={}] ReaderIOException received while waiting for card removal [reason={}]", From e5593c2bb6eaeb69473d97a044e5f2bf35a4df0a Mon Sep 17 00:00:00 2001 From: Jean-Pierre Fortune Date: Tue, 28 Apr 2026 17:52:06 +0200 Subject: [PATCH 3/6] docs: clarify behavior and default for closePhysicalChannel() handling --- .../java/org/eclipse/keyple/plugin/pcsc/PcscReader.java | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReader.java b/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReader.java index b5b9b4e..f7b327c 100644 --- a/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReader.java +++ b/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReader.java @@ -201,14 +201,13 @@ enum DisconnectionMode { PcscReader setIsoProtocol(IsoProtocol isoProtocol); /** - * Changes the action to be taken when {@link - * org.eclipse.keyple.core.plugin.spi.reader.ReaderSpi#closePhysicalChannel()} is called (default - * value {@link DisconnectionMode#RESET}). + * Changes the action to be taken when the internal {@code closePhysicalChannel()} method is + * called (default value {@link DisconnectionMode#RESET}). * *

This setting applies to the forced-close path (e.g. non-observable mode, or abnormal * termination). In observable mode, the channel is closed by {@code deselectCard()} (always - * SCARD_UNPOWER_CARD) before card-removal detection, so {@code closePhysicalChannel()} is - * typically a no-op and this setting has no effect. + * SCARD_UNPOWER_CARD) before card-removal detection, so the internal {@code + * closePhysicalChannel()} method is typically a no-op and this setting has no effect. * *

{@link DisconnectionMode#UNPOWER} and {@link DisconnectionMode#EJECT} require the default * jnasmartcardio provider; they silently fall back to {@link DisconnectionMode#RESET} with other From 2d196ee187164e4835e9764a1b8b1779c2b3771a Mon Sep 17 00:00:00 2001 From: Jean-Pierre Fortune Date: Thu, 30 Apr 2026 09:33:49 +0200 Subject: [PATCH 4/6] feat: add support for SCARD_LEAVE_CARD mode in card state management --- .../java/org/eclipse/keyple/plugin/pcsc/PcscReader.java | 9 +++++++++ .../eclipse/keyple/plugin/pcsc/PcscReaderAdapter.java | 2 ++ 2 files changed, 11 insertions(+) diff --git a/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReader.java b/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReader.java index f7b327c..4848b29 100644 --- a/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReader.java +++ b/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReader.java @@ -119,6 +119,15 @@ enum DisconnectionMode { */ RESET, + /** + * Leaves the card in its current state without performing any reset or power down. + * + *

Corresponds to PC/SC `SCARD_LEAVE_CARD`. + * + * @since 2.0.0 + */ + LEAVE, + /** * Completely powers off the card. * diff --git a/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReaderAdapter.java b/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReaderAdapter.java index 8103cba..d980565 100644 --- a/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReaderAdapter.java +++ b/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReaderAdapter.java @@ -403,6 +403,8 @@ private static int getDisposition(DisconnectionMode mode) { switch (mode) { case RESET: return Smartcardio.JnaCard.SCARD_RESET_CARD; + case LEAVE: + return Smartcardio.JnaCard.SCARD_LEAVE_CARD; case UNPOWER: return Smartcardio.JnaCard.SCARD_UNPOWER_CARD; case EJECT: From 132e856d0e348344796ac7fffe7232817ff85ecf Mon Sep 17 00:00:00 2001 From: Jean-Pierre Fortune Date: Thu, 30 Apr 2026 10:03:55 +0200 Subject: [PATCH 5/6] refactor: replace `cachedPowerOnData` with `powerOnData` and standardize `isPhysicalChannelOpen` usage --- .../keyple/plugin/pcsc/PcscReaderAdapter.java | 44 +++++++++---------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReaderAdapter.java b/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReaderAdapter.java index d980565..6e2831e 100644 --- a/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReaderAdapter.java +++ b/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReaderAdapter.java @@ -53,15 +53,15 @@ final class PcscReaderAdapter private CardChannel channel; private Boolean isContactless; private String protocol = IsoProtocol.ANY.getValue(); - private boolean isModeExclusive = false; + private boolean isModeExclusive; private DisconnectionMode disconnectionMode = DisconnectionMode.RESET; - private boolean physicalChannelOpen = false; - private byte[] cachedPowerOnData = null; + private boolean isPhysicalChannelOpen; + private String powerOnData = ""; private final AtomicBoolean loopWaitCard = new AtomicBoolean(); private final AtomicBoolean loopWaitCardRemoval = new AtomicBoolean(); private boolean isObservationActive; - private boolean isProtocolInnovatronBPrime = false; + private boolean isProtocolInnovatronBPrime; /** * Constructor. @@ -234,8 +234,7 @@ public boolean isCurrentProtocol(String readerProtocol) { String protocolRule = pluginAdapter.getProtocolRule(readerProtocol); boolean isCurrentProtocol; if (protocolRule != null && !protocolRule.isEmpty()) { - String atr = cachedPowerOnData != null ? HexUtil.toHex(cachedPowerOnData) : ""; - isCurrentProtocol = Pattern.compile(protocolRule).matcher(atr).matches(); + isCurrentProtocol = Pattern.compile(protocolRule).matcher(powerOnData).matches(); isProtocolInnovatronBPrime = readerProtocol.equals(PcscCardCommunicationProtocol.INNOVATRON_B_PRIME.name()); } else { @@ -275,7 +274,7 @@ public void onStopDetection() { */ @Override public void deselectCard() { - if (!physicalChannelOpen) { + if (!isPhysicalChannelOpen) { return; } try { @@ -297,8 +296,8 @@ public void deselectCard() { "[readerExt={}] deselectCard: card already removed [reason={}]", name, e.getMessage()); } } finally { - // cachedPowerOnData is intentionally kept: card is physically present in HALT state - physicalChannelOpen = false; + // powerOnData is intentionally kept: card is physically present in HALT state + isPhysicalChannelOpen = false; card = null; channel = null; } @@ -324,7 +323,7 @@ public String getName() { */ @Override public void openPhysicalChannel() throws ReaderIOException, CardIOException { - if (physicalChannelOpen) { + if (isPhysicalChannelOpen) { return; } isProtocolInnovatronBPrime = false; @@ -345,8 +344,8 @@ public void openPhysicalChannel() throws ReaderIOException, CardIOException { } } channel = card.getBasicChannel(); - cachedPowerOnData = card.getATR().getBytes(); - physicalChannelOpen = true; + powerOnData = HexUtil.toHex(card.getATR().getBytes()); + isPhysicalChannelOpen = true; } catch (CardNotPresentException e) { throw new CardIOException("Card removed. Reader: " + name, e); } catch (CardException e) { @@ -368,7 +367,7 @@ public void openPhysicalChannel() throws ReaderIOException, CardIOException { */ @Override public void closePhysicalChannel() throws ReaderIOException { - if (!physicalChannelOpen) { + if (!isPhysicalChannelOpen) { return; } try { @@ -386,10 +385,10 @@ public void closePhysicalChannel() throws ReaderIOException { throw new ReaderIOException("Failed to close the physical channel. Reader: " + name, e); } } finally { - physicalChannelOpen = false; + isPhysicalChannelOpen = false; card = null; channel = null; - cachedPowerOnData = null; + powerOnData = ""; } } @@ -421,7 +420,7 @@ private static int getDisposition(DisconnectionMode mode) { */ @Override public boolean isPhysicalChannelOpen() { - return physicalChannelOpen; + return isPhysicalChannelOpen; } /** @@ -438,7 +437,7 @@ public boolean isPhysicalChannelOpen() { @Override public boolean checkCardPresence() throws ReaderIOException { try { - if (!physicalChannelOpen) { + if (!isPhysicalChannelOpen) { // Canal fermé: attempt connection (performs anti-collision for contactless readers) try { isProtocolInnovatronBPrime = false; @@ -447,8 +446,8 @@ public boolean checkCardPresence() throws ReaderIOException { card.beginExclusive(); } channel = card.getBasicChannel(); - cachedPowerOnData = card.getATR().getBytes(); - physicalChannelOpen = true; + powerOnData = HexUtil.toHex( card.getATR().getBytes()); + isPhysicalChannelOpen = true; return true; } catch (CardNotPresentException e) { return false; @@ -473,14 +472,11 @@ public boolean checkCardPresence() throws ReaderIOException { /** * {@inheritDoc} * - * @since 2.0.0 + * * @since 2.0.0 */ @Override public String getPowerOnData() { - if (cachedPowerOnData == null) { - return ""; - } - return HexUtil.toHex(cachedPowerOnData); + return powerOnData; } /** From d2924235989030bdf47de72c588d75955d0fb10d Mon Sep 17 00:00:00 2001 From: Jean-Pierre Fortune Date: Thu, 30 Apr 2026 10:50:02 +0200 Subject: [PATCH 6/6] fix: ensure correct disconnection mode handling and improve card presence checks --- .../keyple/plugin/pcsc/PcscReaderAdapter.java | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReaderAdapter.java b/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReaderAdapter.java index 6e2831e..cd12d49 100644 --- a/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReaderAdapter.java +++ b/src/main/java/org/eclipse/keyple/plugin/pcsc/PcscReaderAdapter.java @@ -375,7 +375,7 @@ public void closePhysicalChannel() throws ReaderIOException { ((Smartcardio.JnaCard) card).disconnect(getDisposition(disconnectionMode)); } else { // UNPOWER and EJECT are not available outside jnasmartcardio: fall back to RESET - card.disconnect(true); + card.disconnect(disconnectionMode != DisconnectionMode.LEAVE); } } catch (CardException e) { String msg = e.getMessage() != null ? e.getMessage() : ""; @@ -426,11 +426,11 @@ public boolean isPhysicalChannelOpen() { /** * {@inheritDoc} * - *

When the channel is closed (canal fermé), attempts a full {@code SCardConnect()} to perform - * anti-collision for contactless readers. On success the channel is marked open and a subsequent - * call to {@link #openPhysicalChannel()} is a no-op. When the channel is open (canal ouvert), - * checks physical presence via {@code SCardGetStatusChange} and calls {@link - * #closePhysicalChannel()} internally if the card is no longer present. + *

When the channel is closed, attempts a full {@code SCardConnect()} to perform anti-collision + * for contactless readers. On success the channel is marked open and a subsequent call to {@link + * #openPhysicalChannel()} is a no-op. When the channel is open, checks physical presence via + * {@code SCardGetStatusChange} and calls {@link #closePhysicalChannel()} internally if the card + * is no longer present. * * @since 2.0.0 */ @@ -438,7 +438,7 @@ public boolean isPhysicalChannelOpen() { public boolean checkCardPresence() throws ReaderIOException { try { if (!isPhysicalChannelOpen) { - // Canal fermé: attempt connection (performs anti-collision for contactless readers) + // channel closed: attempt connection (performs anti-collision for contactless readers) try { isProtocolInnovatronBPrime = false; card = communicationTerminal.connect(protocol); @@ -446,14 +446,14 @@ public boolean checkCardPresence() throws ReaderIOException { card.beginExclusive(); } channel = card.getBasicChannel(); - powerOnData = HexUtil.toHex( card.getATR().getBytes()); + powerOnData = HexUtil.toHex(card.getATR().getBytes()); isPhysicalChannelOpen = true; return true; } catch (CardNotPresentException e) { return false; } } else { - // Canal ouvert: verify card still responds + // channel open: verify card still responds boolean isPresent = communicationTerminal.isCardPresent(); if (!isPresent) { try { @@ -472,7 +472,7 @@ public boolean checkCardPresence() throws ReaderIOException { /** * {@inheritDoc} * - * * @since 2.0.0 + *

* @since 2.0.0 */ @Override public String getPowerOnData() {