Skip to content

Commit 7a7ea91

Browse files
authored
Add PC/SC backend adapter (#3)
2 parents f7c1f5a + ec7649f commit 7a7ea91

18 files changed

Lines changed: 603 additions & 18 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ known-third-party = []
8888
root = ["src"]
8989

9090
[tool.ty.src]
91-
include = ["src/ntag424dna", "tests"]
91+
include = ["src/schnee", "tests"]
9292

9393

9494
[tool.pytest.ini_options]

src/schnee/__init__.py

Whitespace-only changes.

src/schnee/adapters/__init__.py

Whitespace-only changes.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .contracts import ProfileBackend, ProfileReaderBackend
2+
from .core import Backend
3+
from .pcsc import PcscApduClient, PcscBackend
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
4+
5+
if TYPE_CHECKING:
6+
from schnee.adapters.ntag.profile import ChangePlan, TagProfile
7+
8+
9+
@runtime_checkable
10+
class ProfileReaderBackend(Protocol):
11+
"""Backend adapter that can read an NTAG profile."""
12+
13+
def read_profile(self) -> TagProfile:
14+
"""Read the current tag profile."""
15+
16+
17+
@runtime_checkable
18+
class ProfileBackend(ProfileReaderBackend, Protocol):
19+
"""Backend adapter that can read and write an NTAG profile."""
20+
21+
def apply_plan(self, plan: ChangePlan) -> TagProfile:
22+
"""Apply a profile change plan."""
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
from .pcsc import PcscBackend
6+
7+
if TYPE_CHECKING:
8+
from .contracts import ProfileBackend
9+
10+
11+
class Backend:
12+
"""Backend adapter selector."""
13+
14+
class BackendError(Exception):
15+
"""Backend error."""
16+
17+
class BackendNotFoundError(BackendError):
18+
"""Backend not found."""
19+
20+
@classmethod
21+
def backend_names(cls) -> list[str]:
22+
"""Get all backend names."""
23+
_ = cls
24+
pcsc_names = PcscBackend.pcsc_backend_names()
25+
names = [f"pcsc:{name}" for name in pcsc_names]
26+
if pcsc_names:
27+
names.append("pcsc")
28+
return sorted(names)
29+
30+
@classmethod
31+
def get(cls, name: str) -> ProfileBackend:
32+
"""Get a backend adapter by name."""
33+
_ = cls
34+
if name == "pcsc":
35+
return PcscBackend.create_pcsc_backend()
36+
if name.startswith("pcsc:"):
37+
return PcscBackend.create_pcsc_backend(name.removeprefix("pcsc:"))
38+
raise cls.BackendNotFoundError
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from .backend import PcscBackend
2+
from .client import PcscApduClient
3+
from .reader import (
4+
PcscConnection,
5+
PcscReader,
6+
PcscReaderProvider,
7+
SmartcardPcscConnection,
8+
SmartcardPcscReader,
9+
)
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
from schnee.adapters.ntag.profile import ChangePlan, TagInfo, TagProfile
6+
7+
from .client import PcscApduClient
8+
from .reader import PcscConnection, PcscReader, PcscReaderProvider
9+
10+
if TYPE_CHECKING:
11+
from schnee.adapters.ntag.apdu import CommandAPDU, ResponseAPDU
12+
13+
14+
class PcscBackend:
15+
"""Backend adapter that wraps a PC/SC reader."""
16+
17+
class PcscBackendError(Exception):
18+
"""PC/SC backend error."""
19+
20+
class UnsupportedPlanError(PcscBackendError):
21+
"""Raised when a write plan is not implemented by the PC/SC backend."""
22+
23+
class UnsupportedProfileReadError(PcscBackendError):
24+
"""Raised when full profile reads are not implemented."""
25+
26+
def __init__(self, reader: PcscReader) -> None:
27+
self.reader = reader
28+
self.client = PcscApduClient(reader)
29+
30+
@property
31+
def reader_name(self) -> str:
32+
"""Return the wrapped PC/SC reader name."""
33+
return self.client.reader_name
34+
35+
def connect(self) -> PcscConnection:
36+
"""Connect to the wrapped PC/SC reader."""
37+
return self.client.connect()
38+
39+
def send_apdu(self, apdu: CommandAPDU | list[int]) -> ResponseAPDU:
40+
"""Transmit an APDU through the wrapped PC/SC reader."""
41+
return self.client.send_apdu(apdu)
42+
43+
def read_profile(self) -> TagProfile:
44+
"""Read the currently reachable NTAG profile."""
45+
msg = "PC/SC full profile reads are not implemented yet"
46+
raise self.UnsupportedProfileReadError(msg)
47+
48+
def read_tag_info(self) -> TagInfo:
49+
"""Read the currently reachable NTAG tag identity summary."""
50+
uid = self._read_uid()
51+
return TagInfo(
52+
uid=uid,
53+
features=["pcsc"],
54+
)
55+
56+
def apply_plan(self, plan: ChangePlan) -> TagProfile:
57+
"""Apply a profile change plan through PC/SC."""
58+
_ = plan
59+
msg = "PC/SC profile writes are not implemented yet"
60+
raise self.UnsupportedPlanError(msg)
61+
62+
def _read_uid(self) -> str | None:
63+
"""Read UID using the common PC/SC contactless reader command."""
64+
response = self.send_apdu([0xFF, 0xCA, 0x00, 0x00, 0x00])
65+
if not response.ok:
66+
return None
67+
return bytes(response.data).hex().upper()
68+
69+
@classmethod
70+
def create_pcsc_backend(cls, reader_name: str | None = None) -> PcscBackend:
71+
"""Create a PC/SC backend adapter."""
72+
if reader_name is not None:
73+
return cls(reader=PcscReaderProvider.get(reader_name))
74+
75+
readers = PcscReaderProvider.readers()
76+
if not readers:
77+
raise PcscReaderProvider.ReaderNotFoundError
78+
return cls(reader=readers[0])
79+
80+
@staticmethod
81+
def pcsc_backend_names() -> list[str]:
82+
"""Return available PC/SC reader backend names without the pcsc prefix."""
83+
return list(PcscReaderProvider.reader_names())
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING, ClassVar
4+
5+
from schnee.adapters.ntag.apdu import CommandAPDU, ResponseAPDU
6+
7+
if TYPE_CHECKING:
8+
from .reader import PcscConnection, PcscReader
9+
10+
11+
class PcscApduClient:
12+
"""PC/SC APDU transport client."""
13+
14+
ok_statuses: ClassVar[tuple[tuple[int, int], ...]] = (
15+
(0x90, 0x00),
16+
(0x91, 0x00),
17+
(0x91, 0xAF),
18+
)
19+
20+
class PcscApduClientError(Exception):
21+
"""PC/SC APDU client error."""
22+
23+
class ApduStatusError(PcscApduClientError):
24+
"""Raised when a response APDU has an unsuccessful status."""
25+
26+
def __init__(self, sw1: int, sw2: int) -> None:
27+
self.sw1 = sw1
28+
self.sw2 = sw2
29+
super().__init__(f"SW1: {hex(sw1)} SW2: {hex(sw2)}")
30+
31+
def __init__(self, reader: PcscReader) -> None:
32+
self.reader = reader
33+
self.connection: PcscConnection | None = None
34+
35+
@property
36+
def reader_name(self) -> str:
37+
"""Return the wrapped PC/SC reader name."""
38+
return self.reader.name
39+
40+
def connect(self) -> PcscConnection:
41+
"""Connect to the wrapped PC/SC reader."""
42+
if self.connection is None:
43+
self.connection = self.reader.create_connection()
44+
self.connection.connect()
45+
return self.connection
46+
47+
def send_apdu(self, apdu: CommandAPDU | list[int]) -> ResponseAPDU:
48+
"""Transmit an APDU and return the full response."""
49+
command = apdu.to_list() if isinstance(apdu, CommandAPDU) else apdu
50+
response, sw1, sw2 = self.connect().transmit(command)
51+
return ResponseAPDU(data=response, sw1=sw1, sw2=sw2)
52+
53+
def send_checked(
54+
self,
55+
apdu: CommandAPDU | list[int],
56+
*,
57+
ok_statuses: tuple[tuple[int, int], ...] | None = None,
58+
) -> list[int]:
59+
"""Transmit an APDU and return data for successful status words."""
60+
response = self.send_apdu(apdu)
61+
statuses = self.ok_statuses if ok_statuses is None else ok_statuses
62+
if (response.sw1, response.sw2) in statuses:
63+
return response.data
64+
raise self.ApduStatusError(response.sw1, response.sw2)
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING, Protocol
4+
5+
from smartcard.pcsc.PCSCReader import PCSCReader
6+
from smartcard.System import readers as smartcard_readers
7+
8+
if TYPE_CHECKING:
9+
from smartcard.CardConnection import CardConnection
10+
11+
12+
class PcscConnection(Protocol):
13+
"""PC/SC connection interface used by the backend."""
14+
15+
def connect(self) -> None:
16+
"""Connect to the card."""
17+
18+
def transmit(self, command: list[int]) -> tuple[list[int], int, int]:
19+
"""Transmit a command APDU."""
20+
21+
22+
class PcscReader(Protocol):
23+
"""PC/SC reader interface used by the backend."""
24+
25+
name: str
26+
27+
def create_connection(self) -> PcscConnection:
28+
"""Create a PC/SC connection."""
29+
30+
31+
class SmartcardPcscConnection:
32+
"""Adapter for pyscard connection objects."""
33+
34+
def __init__(self, connection: CardConnection) -> None:
35+
self._connection = connection
36+
37+
def connect(self) -> None:
38+
"""Connect to the card."""
39+
self._connection.connect()
40+
41+
def transmit(self, command: list[int]) -> tuple[list[int], int, int]:
42+
"""Transmit a command APDU."""
43+
response, sw1, sw2 = self._connection.transmit(command)
44+
return list(response), sw1, sw2
45+
46+
47+
class SmartcardPcscReader:
48+
"""Adapter for pyscard PC/SC reader objects."""
49+
50+
def __init__(self, reader: PCSCReader) -> None:
51+
self._reader = reader
52+
53+
@property
54+
def name(self) -> str:
55+
"""Return the PC/SC reader name."""
56+
return self._reader.name
57+
58+
def create_connection(self) -> SmartcardPcscConnection:
59+
"""Create a wrapped PC/SC connection."""
60+
return SmartcardPcscConnection(self._reader.createConnection())
61+
62+
63+
class PcscReaderProvider:
64+
"""Discover and wrap pyscard PC/SC readers."""
65+
66+
class PcscReaderProviderError(Exception):
67+
"""PC/SC reader provider error."""
68+
69+
class ReaderNotFoundError(PcscReaderProviderError):
70+
"""Raised when a requested PC/SC reader is not found."""
71+
72+
@staticmethod
73+
def readers() -> list[PcscReader]:
74+
"""Return available PC/SC readers."""
75+
return [
76+
SmartcardPcscReader(reader)
77+
for reader in smartcard_readers()
78+
if isinstance(reader, PCSCReader)
79+
]
80+
81+
@classmethod
82+
def reader_names(cls) -> list[str]:
83+
"""Return available PC/SC reader names."""
84+
return [reader.name for reader in cls.readers()]
85+
86+
@classmethod
87+
def get(cls, name: str) -> PcscReader:
88+
"""Return a named PC/SC reader."""
89+
for reader in cls.readers():
90+
if reader.name == name:
91+
return reader
92+
raise cls.ReaderNotFoundError

0 commit comments

Comments
 (0)