Skip to content

Commit 3e859e4

Browse files
authored
Add shared NDEF URI prefix table (#43)
2 parents e33edf1 + c6dc020 commit 3e859e4

5 files changed

Lines changed: 168 additions & 14 deletions

File tree

src/schnee/adapters/ntag/profile/models.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from typing import TYPE_CHECKING, Literal, Self
66

7-
from pydantic import BaseModel, Field, HttpUrl, model_validator
7+
from pydantic import AnyUrl, BaseModel, Field, HttpUrl, model_validator
88

99
if TYPE_CHECKING:
1010
from .planning import ChangePlan
@@ -22,7 +22,7 @@ class NdefRecord(BaseModel):
2222
def validate_record(self) -> Self:
2323
"""Validate record-specific payload constraints."""
2424
if self.type == "url":
25-
HttpUrl(self.value)
25+
AnyUrl(self.value)
2626
return self
2727

2828

src/schnee/adapters/ntag/profile/ndef.py

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
from typing import ClassVar
66

7+
from schnee.ndef import NdefUriPrefix
8+
79
from .models import NdefProfile, NdefRecord
810

911

@@ -143,18 +145,12 @@ def _parse_uri_payload(cls, payload: bytes) -> str:
143145
msg = "URI NDEF payload is empty"
144146
raise cls.NdefParseError(msg)
145147

146-
prefixes = {
147-
0x00: "",
148-
0x01: "http://www.",
149-
0x02: "https://www.",
150-
0x03: "http://",
151-
0x04: "https://",
152-
}
153-
prefix = prefixes.get(payload[0])
154-
if prefix is None:
155-
msg = f"Unsupported URI identifier code: {payload[0]:#x}"
156-
raise cls.NdefParseError(msg)
157-
return f"{prefix}{payload[1:].decode()}"
148+
try:
149+
prefix = NdefUriPrefix.from_code(payload[0])
150+
except ValueError as exc:
151+
msg = str(exc)
152+
raise cls.NdefParseError(msg) from exc
153+
return f"{prefix.expanded_text}{payload[1:].decode()}"
158154

159155
@classmethod
160156
def _parse_text_payload(cls, payload: bytes) -> str:

src/schnee/ndef.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Shared NDEF data models."""
2+
3+
from __future__ import annotations
4+
5+
from enum import Enum, unique
6+
7+
8+
@unique
9+
class NdefUriPrefix(Enum):
10+
"""NDEF URI Identifier Code prefix table."""
11+
12+
NO_PREFIX = (0x00, "")
13+
HTTP_WWW = (0x01, "http://www.")
14+
HTTPS_WWW = (0x02, "https://www.")
15+
HTTP = (0x03, "http://")
16+
HTTPS = (0x04, "https://")
17+
TEL = (0x05, "tel:")
18+
MAILTO = (0x06, "mailto:")
19+
FTP_ANONYMOUS = (0x07, "ftp://anonymous:anonymous@")
20+
FTP_FTP = (0x08, "ftp://ftp.")
21+
FTPS = (0x09, "ftps://")
22+
SFTP = (0x0A, "sftp://")
23+
SMB = (0x0B, "smb://")
24+
NFS = (0x0C, "nfs://")
25+
FTP = (0x0D, "ftp://")
26+
DAV = (0x0E, "dav://")
27+
NEWS = (0x0F, "news:")
28+
TELNET = (0x10, "telnet://")
29+
IMAP = (0x11, "imap:")
30+
RTSP = (0x12, "rtsp://")
31+
URN = (0x13, "urn:")
32+
POP = (0x14, "pop:")
33+
SIP = (0x15, "sip:")
34+
SIPS = (0x16, "sips:")
35+
TFTP = (0x17, "tftp:")
36+
BTSPP = (0x18, "btspp://")
37+
BTL2CAP = (0x19, "btl2cap://")
38+
BTGOEP = (0x1A, "btgoep://")
39+
TCPOBEX = (0x1B, "tcpobex://")
40+
IRDAOBEX = (0x1C, "irdaobex://")
41+
FILE = (0x1D, "file://")
42+
URN_EPC_ID = (0x1E, "urn:epc:id:")
43+
URN_EPC_TAG = (0x1F, "urn:epc:tag:")
44+
URN_EPC_PAT = (0x20, "urn:epc:pat:")
45+
URN_EPC_RAW = (0x21, "urn:epc:raw:")
46+
URN_EPC = (0x22, "urn:epc:")
47+
URN_NFC = (0x23, "urn:nfc:")
48+
49+
def __init__(self, code: int, expanded_text: str) -> None:
50+
self._code = code
51+
self._expanded_text = expanded_text
52+
53+
@property
54+
def code(self) -> int:
55+
"""Return the numeric URI Identifier Code."""
56+
return self._code
57+
58+
@property
59+
def expanded_text(self) -> str:
60+
"""Return the expanded text represented by the prefix code."""
61+
return self._expanded_text
62+
63+
@classmethod
64+
def from_code(cls, code: int) -> NdefUriPrefix:
65+
"""Return the URI prefix for a numeric URI Identifier Code."""
66+
try:
67+
return _NDEF_URI_PREFIXES_BY_CODE[code]
68+
except KeyError:
69+
msg = f"Unsupported URI identifier code: {code:#x}"
70+
raise ValueError(msg) from None
71+
72+
73+
_NDEF_URI_PREFIXES_BY_CODE: dict[int, NdefUriPrefix] = {
74+
prefix.code: prefix for prefix in NdefUriPrefix
75+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""Tests for NDEF profile parsing."""
2+
3+
import pytest
4+
5+
from schnee.adapters.ntag.profile.ndef import NdefProfileParser
6+
7+
8+
def _uri_record(prefix_code: int, suffix: bytes) -> list[int]:
9+
payload = [prefix_code, *suffix]
10+
return [
11+
0xD1,
12+
0x01,
13+
len(payload),
14+
0x55,
15+
*payload,
16+
]
17+
18+
19+
@pytest.mark.parametrize(
20+
("prefix_code", "suffix", "expected"),
21+
[
22+
(0x04, b"example.com", "https://example.com"),
23+
(0x05, b"+81312345678", "tel:+81312345678"),
24+
(0x1D, b"/tmp/tag.txt", "file:///tmp/tag.txt"),
25+
(0x23, b"sn:example", "urn:nfc:sn:example"),
26+
],
27+
)
28+
def test_parse_uri_record_uses_shared_prefix_table(
29+
prefix_code: int,
30+
suffix: bytes,
31+
expected: str,
32+
) -> None:
33+
"""URI records expand NDEF URI Identifier Code prefixes."""
34+
records = NdefProfileParser.parse_message(_uri_record(prefix_code, suffix))
35+
36+
assert len(records) == 1, "URI NDEF message should produce one profile record"
37+
assert records[0].type == "url", "URI NDEF record should be represented as a URL"
38+
assert records[0].value == expected, (
39+
"URI NDEF record should expand the identifier code prefix"
40+
)
41+
42+
43+
def test_parse_uri_record_rejects_unsupported_prefix_code() -> None:
44+
"""URI records reject undefined NDEF URI Identifier Code prefixes."""
45+
with pytest.raises(
46+
NdefProfileParser.NdefParseError,
47+
match="Unsupported URI identifier code: 0x24",
48+
):
49+
NdefProfileParser.parse_message(_uri_record(0x24, b"example.com"))

tests/schnee/test_ndef.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Tests for shared NDEF models."""
2+
3+
import pytest
4+
5+
from schnee.ndef import NdefUriPrefix
6+
7+
URN_NFC_PREFIX_CODE = 0x23
8+
9+
10+
def test_ndef_uri_prefix_covers_complete_identifier_code_range() -> None:
11+
"""NDEF URI prefixes expose every defined identifier code."""
12+
codes = [prefix.code for prefix in NdefUriPrefix]
13+
14+
assert codes == list(range(0x24)), (
15+
"NDEF URI prefixes should cover every identifier code from 0x00 to 0x23"
16+
)
17+
18+
19+
def test_ndef_uri_prefix_exposes_code_and_expanded_text() -> None:
20+
"""NDEF URI prefix members expose numeric and expanded forms."""
21+
prefix = NdefUriPrefix.URN_NFC
22+
23+
assert prefix.code == URN_NFC_PREFIX_CODE, (
24+
"URN_NFC should expose its numeric identifier code"
25+
)
26+
assert prefix.expanded_text == "urn:nfc:", (
27+
"URN_NFC should expose its expanded URI prefix text"
28+
)
29+
30+
31+
def test_ndef_uri_prefix_from_code_rejects_unsupported_code() -> None:
32+
"""NDEF URI prefix lookup rejects undefined identifier codes."""
33+
with pytest.raises(ValueError, match="Unsupported URI identifier code: 0x24"):
34+
NdefUriPrefix.from_code(0x24)

0 commit comments

Comments
 (0)