Migrates all 43 sim modules and 23 test files (~7.8k LOC, 686 tests) from .worktrees/sim-framework/ into pm3py/sim/. Import fixes: - 4 files: ..protocol/..transport → ..core.protocol/..core.transport - trace_fmt.py: pm3py.hf_15 → pm3py.trace.ndef - test_sim_pm3medium.py: flat imports → core.* - test_sim_trace_fmt.py: flat imports → core.* - Added sim Cmd entries to core/protocol.py (EML_SETMEM, SIM_TABLE_*) 751 tests passing (686 sim + 65 core). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1017 lines
40 KiB
Python
1017 lines
40 KiB
Python
"""Tests for NFC Type 5, NXP ICODE platform, and ICODE SLIX2."""
|
|
import asyncio
|
|
import struct
|
|
import pytest
|
|
|
|
from pm3py.sim.frame import RFFrame
|
|
from pm3py.sim.medium import SoftwareMedium
|
|
from pm3py.sim.type5 import NfcType5Tag
|
|
from pm3py.sim.nxp_icode import NxpIcodeTag
|
|
from pm3py.sim.icode_slix import IcodeSlixTag
|
|
from pm3py.sim.icode_slix2 import (
|
|
IcodeSlix2Tag, PWD_EAS_AFI, PWD_READ, PWD_WRITE, PWD_PRIVACY, PWD_DESTROY,
|
|
)
|
|
from pm3py.sim.nxp_icode import (
|
|
CMD_SET_EAS, CMD_RESET_EAS, CMD_LOCK_EAS, CMD_EAS_ALARM,
|
|
CMD_PASSWORD_PROTECT_EAS_AFI, CMD_WRITE_EAS_ID, CMD_INVENTORY_READ,
|
|
CMD_FAST_INVENTORY_READ,
|
|
)
|
|
from pm3py.sim.iso15693 import Reader15693
|
|
|
|
|
|
def run(coro):
|
|
return asyncio.get_event_loop().run_until_complete(coro)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# NFC Type 5 Tag
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestNfcType5Tag:
|
|
def test_cc_in_block_0(self):
|
|
"""Block 0 contains capability container with MBREAD flag."""
|
|
tag = NfcType5Tag(uid=bytes(8), ndef_message=b"\xD1\x01\x04\x54\x02enHi",
|
|
block_size=4, num_blocks=28)
|
|
run(tag.power_on())
|
|
|
|
read_cmd = bytes([0x02, 0x20, 0x00])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(read_cmd)))
|
|
cc = resp.data[1:5]
|
|
assert cc[0] == 0xE1 # NDEF magic
|
|
assert cc[1] == 0x40 # version 1.0, read/write
|
|
assert cc[3] & 0x01 # MBREAD supported — required for phone NDEF reads
|
|
|
|
def test_ndef_tlv_in_block_1(self):
|
|
"""NDEF TLV starts in block 1."""
|
|
msg = b"\xD1\x01\x04\x54\x02enHi"
|
|
tag = NfcType5Tag(uid=bytes(8), ndef_message=msg, block_size=4, num_blocks=28)
|
|
run(tag.power_on())
|
|
|
|
read_cmd = bytes([0x02, 0x20, 0x01])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(read_cmd)))
|
|
data = resp.data[1:5]
|
|
assert data[0] == 0x03 # NDEF TLV type
|
|
assert data[1] == len(msg)
|
|
|
|
def test_inherits_15693_inventory(self):
|
|
"""Type 5 tags respond to ISO 15693 inventory."""
|
|
medium = SoftwareMedium()
|
|
tag = NfcType5Tag(uid=b"\xE0\x04\x01\x02\x03\x04\x05\x06",
|
|
ndef_message=b"")
|
|
run(medium.attach(tag))
|
|
reader = Reader15693(medium)
|
|
|
|
tags = run(reader.inventory())
|
|
assert len(tags) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# NXP ICODE platform (shared features)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestNxpIcodeTag:
|
|
def test_inherits_type5(self):
|
|
tag = NxpIcodeTag(uid=bytes(8))
|
|
run(tag.power_on())
|
|
|
|
# Should respond to inventory
|
|
inv = bytes([0x26, 0x01, 0x00])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(inv)))
|
|
assert resp is not None
|
|
|
|
def test_get_random_number(self):
|
|
"""NXP GET_RANDOM_NUMBER (0xB2) returns 2 random bytes."""
|
|
tag = NxpIcodeTag(uid=b"\xE0\x04" + bytes(6))
|
|
run(tag.power_on())
|
|
|
|
cmd = bytes([0x02, 0xB2, 0x04])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert len(resp.data) >= 3 # flags + 2 random bytes
|
|
|
|
def test_get_nxp_system_info(self):
|
|
"""GET NXP SYSTEM INFO (0xAB) returns PP + feature data on SLIX2."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5))
|
|
run(tag.power_on())
|
|
|
|
cmd = bytes([0x02, 0xAB, 0x04])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] == 0x00 # success flags
|
|
assert len(resp.data) >= 8 # flags + PP + PP_cond + lock + feature_flags
|
|
|
|
def test_read_signature(self):
|
|
"""READ SIGNATURE (0xBD) returns 32-byte ECC signature on SLIX2."""
|
|
sig = bytes(range(32))
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5), signature=sig)
|
|
run(tag.power_on())
|
|
|
|
cmd = bytes([0x02, 0xBD, 0x04])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] == 0x00 # success
|
|
assert resp.data[1:] == sig
|
|
|
|
def test_read_signature_default_zeros(self):
|
|
"""READ SIGNATURE returns 32 zero bytes when no signature configured on SLIX2."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5))
|
|
run(tag.power_on())
|
|
|
|
cmd = bytes([0x02, 0xBD, 0x04])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert len(resp.data) == 33 # flags + 32 bytes
|
|
assert resp.data[1:] == bytes(32)
|
|
|
|
def test_wrong_mfg_code_ignored(self):
|
|
"""Commands with wrong manufacturer code return None."""
|
|
tag = NxpIcodeTag(uid=bytes(8))
|
|
run(tag.power_on())
|
|
|
|
cmd = bytes([0x02, 0xB2, 0x05]) # mfg=0x05, not NXP
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
# Should fall through (not an NXP custom command match)
|
|
# Depending on base class behavior, may return None or error
|
|
# The key is it shouldn't return a valid GET_RANDOM response
|
|
|
|
def test_slix_rejects_get_nxp_system_info(self):
|
|
"""SLIX (SL2S2002) does NOT support GET NXP SYSTEM INFO (0xAB)."""
|
|
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4))
|
|
run(tag.power_on())
|
|
|
|
cmd = bytes([0x02, 0xAB, 0x04])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is None
|
|
|
|
def test_slix_rejects_read_signature(self):
|
|
"""SLIX (SL2S2002) does NOT support READ SIGNATURE (0xBD)."""
|
|
tag = IcodeSlixTag(uid=b"\xE0\x04\x01\x10" + bytes(4))
|
|
run(tag.power_on())
|
|
|
|
cmd = bytes([0x02, 0xBD, 0x04])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ICODE SLIX2
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestIcodeSlix2Tag:
|
|
def test_inherits_nxp_icode(self):
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5))
|
|
run(tag.power_on())
|
|
|
|
inv = bytes([0x26, 0x01, 0x00])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(inv)))
|
|
assert resp is not None
|
|
|
|
def test_privacy_mode_hides_uid(self):
|
|
"""In privacy mode, tag doesn't respond to inventory."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
privacy_password=0xDEADBEEF)
|
|
run(tag.power_on())
|
|
|
|
tag.enter_privacy_mode()
|
|
assert tag.privacy_mode
|
|
|
|
inv = bytes([0x26, 0x01, 0x00])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(inv)))
|
|
assert resp is None # hidden
|
|
|
|
def test_privacy_mode_allows_get_random(self):
|
|
"""In privacy mode, GET_RANDOM still works (needed for password XOR)."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
privacy_password=0xDEADBEEF)
|
|
run(tag.power_on())
|
|
tag.enter_privacy_mode()
|
|
|
|
cmd = bytes([0x02, 0xB2, 0x04])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert len(resp.data) >= 3
|
|
|
|
def test_privacy_mode_unlock_with_xor(self):
|
|
"""SET_PASSWORD with correct XOR'd privacy password disables privacy mode."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
privacy_password=0xDEADBEEF)
|
|
run(tag.power_on())
|
|
tag.enter_privacy_mode()
|
|
|
|
# First get random for XOR
|
|
cmd = bytes([0x02, 0xB2, 0x04])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
r = resp.data[1:3]
|
|
|
|
# XOR the password
|
|
pwd = struct.pack("<I", 0xDEADBEEF)
|
|
xored = bytes([pwd[0] ^ r[0], pwd[1] ^ r[1], pwd[2] ^ r[0], pwd[3] ^ r[1]])
|
|
|
|
cmd = bytes([0x02, 0xB3, 0x04, 0x04]) + xored
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] == 0x00 # success
|
|
assert not tag.privacy_mode
|
|
|
|
def test_enable_privacy_command(self):
|
|
"""ENABLE_PRIVACY (0xBA) enters privacy mode with XOR'd password."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
privacy_password=0xDEADBEEF)
|
|
run(tag.power_on())
|
|
assert not tag.privacy_mode
|
|
|
|
# Get random
|
|
cmd = bytes([0x02, 0xB2, 0x04])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
r = resp.data[1:3]
|
|
|
|
# XOR the password
|
|
pwd = struct.pack("<I", 0xDEADBEEF)
|
|
xored = bytes([pwd[0] ^ r[0], pwd[1] ^ r[1], pwd[2] ^ r[0], pwd[3] ^ r[1]])
|
|
|
|
cmd = bytes([0x02, 0xBA, 0x04]) + xored
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] == 0x00
|
|
assert tag.privacy_mode
|
|
|
|
def test_enable_privacy_wrong_password(self):
|
|
"""ENABLE_PRIVACY with wrong password fails."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
privacy_password=0xDEADBEEF)
|
|
run(tag.power_on())
|
|
|
|
# Wrong password (not XOR'd correctly)
|
|
cmd = bytes([0x02, 0xBA, 0x04, 0x00, 0x00, 0x00, 0x00])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] & 0x01 # error flag
|
|
assert not tag.privacy_mode
|
|
|
|
def test_wrong_password(self):
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
privacy_password=0xDEADBEEF)
|
|
run(tag.power_on())
|
|
tag.enter_privacy_mode()
|
|
|
|
cmd = bytes([0x02, 0xB3, 0x04, 0x04]) + struct.pack("<I", 0x00000000)
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is None or (resp.data[0] & 0x01) # error
|
|
assert tag.privacy_mode # still private
|
|
|
|
def test_eas_flag(self):
|
|
"""EAS (Electronic Article Surveillance) state management."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5))
|
|
run(tag.power_on())
|
|
assert not tag.eas_enabled
|
|
|
|
tag.set_eas(True)
|
|
assert tag.eas_enabled
|
|
|
|
tag.set_eas(False)
|
|
assert not tag.eas_enabled
|
|
|
|
def test_eas_afi_password(self):
|
|
"""EAS/AFI password (pwd_id 0x10) is supported."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
eas_afi_password=0x11223344)
|
|
assert PWD_EAS_AFI in tag._passwords
|
|
assert tag._passwords[PWD_EAS_AFI] == 0x11223344
|
|
|
|
def test_password_protection_read(self):
|
|
"""Read password controls read access to protected blocks."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5), read_password=0xAABBCCDD,
|
|
protection_pointer=4) # blocks 4+ protected
|
|
run(tag.power_on())
|
|
|
|
# Read unprotected block 0 — should work
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(bytes([0x02, 0x20, 0x00]))))
|
|
assert resp is not None and resp.data[0] == 0x00
|
|
|
|
# Read protected block 4 — should fail without password
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(bytes([0x02, 0x20, 0x04]))))
|
|
assert resp is not None and resp.data[0] & 0x01 # error flag
|
|
|
|
def test_get_nxp_system_info_with_protection(self):
|
|
"""GET NXP SYSTEM INFO reflects protection pointer."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5), protection_pointer=10)
|
|
run(tag.power_on())
|
|
|
|
cmd = bytes([0x02, 0xAB, 0x04])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] == 0x00 # success
|
|
assert resp.data[1] == 10 # PP = 10
|
|
assert resp.data[2] & 0x01 # read protection condition set
|
|
|
|
def test_protect_page(self):
|
|
"""PROTECT PAGE (0xB6) sets protection pointer after auth."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
write_password=0x12345678)
|
|
run(tag.power_on())
|
|
|
|
# Authenticate with write password first
|
|
cmd = bytes([0x02, 0xB2, 0x04])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
r = resp.data[1:3]
|
|
|
|
pwd = struct.pack("<I", 0x12345678)
|
|
xored = bytes([pwd[0] ^ r[0], pwd[1] ^ r[1], pwd[2] ^ r[0], pwd[3] ^ r[1]])
|
|
cmd = bytes([0x02, 0xB3, 0x04, 0x02]) + xored # pwd_id=0x02 (write)
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp.data[0] == 0x00 # auth success
|
|
|
|
# Now PROTECT PAGE
|
|
cmd = bytes([0x02, 0xB6, 0x04, 0x08, 0x01]) # PP=8, cond=read_prot
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] == 0x00
|
|
assert tag._protection_pointer == 8
|
|
|
|
def test_protect_page_requires_auth(self):
|
|
"""PROTECT PAGE fails without write password auth."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
write_password=0x12345678)
|
|
run(tag.power_on())
|
|
|
|
cmd = bytes([0x02, 0xB6, 0x04, 0x08, 0x01])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] & 0x01 # error — not authenticated
|
|
|
|
def test_read_signature_inherited(self):
|
|
"""SLIX2 inherits READ_SIGNATURE from NxpIcodeTag."""
|
|
sig = b"\xAA" * 32
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5), signature=sig)
|
|
run(tag.power_on())
|
|
|
|
cmd = bytes([0x02, 0xBD, 0x04])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] == 0x00
|
|
assert resp.data[1:] == sig
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helper: GET_RANDOM + XOR password
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _xor_password(tag, pwd_value: int) -> bytes:
|
|
"""Get random from tag, return XOR'd password bytes."""
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(bytes([0x02, 0xB2, 0x04]))))
|
|
r = resp.data[1:3]
|
|
pwd = struct.pack("<I", pwd_value)
|
|
return bytes([pwd[0] ^ r[0], pwd[1] ^ r[1], pwd[2] ^ r[0], pwd[3] ^ r[1]])
|
|
|
|
|
|
def _authenticate(tag, pwd_id: int, pwd_value: int):
|
|
"""GET_RANDOM + SET_PASSWORD for a given password ID."""
|
|
xored = _xor_password(tag, pwd_value)
|
|
cmd = bytes([0x02, 0xB3, 0x04, pwd_id]) + xored
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp.data[0] == 0x00, f"auth failed for pwd_id {pwd_id:#x}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WRITE PASSWORD (0xB4)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestWritePassword:
|
|
def test_write_password_changes_value(self):
|
|
"""WRITE PASSWORD (0xB4) changes password after auth."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
read_password=0x11111111)
|
|
run(tag.power_on())
|
|
|
|
# Auth with current read password
|
|
_authenticate(tag, PWD_READ, 0x11111111)
|
|
|
|
# Write new password (XOR'd with fresh random)
|
|
new_xored = _xor_password(tag, 0x22222222)
|
|
cmd = bytes([0x02, 0xB4, 0x04, PWD_READ]) + new_xored
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] == 0x00 # success
|
|
|
|
# Old password should fail, new should work
|
|
# Need fresh random for each attempt
|
|
xored_old = _xor_password(tag, 0x11111111)
|
|
cmd = bytes([0x02, 0xB3, 0x04, PWD_READ]) + xored_old
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp.data[0] & 0x01 # error — old password rejected
|
|
|
|
xored_new = _xor_password(tag, 0x22222222)
|
|
cmd = bytes([0x02, 0xB3, 0x04, PWD_READ]) + xored_new
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp.data[0] == 0x00 # new password works
|
|
|
|
def test_write_password_requires_auth(self):
|
|
"""WRITE PASSWORD fails without prior SET_PASSWORD."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
read_password=0x11111111)
|
|
run(tag.power_on())
|
|
|
|
new_xored = _xor_password(tag, 0x22222222)
|
|
cmd = bytes([0x02, 0xB4, 0x04, PWD_READ]) + new_xored
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] & 0x01 # error
|
|
|
|
def test_write_password_wrong_pwd_id(self):
|
|
"""WRITE PASSWORD for non-existent password fails."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
read_password=0x11111111)
|
|
run(tag.power_on())
|
|
_authenticate(tag, PWD_READ, 0x11111111)
|
|
|
|
# Try to write a password that doesn't exist (destroy)
|
|
new_xored = _xor_password(tag, 0x33333333)
|
|
cmd = bytes([0x02, 0xB4, 0x04, PWD_DESTROY]) + new_xored
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp.data[0] & 0x01 # error — not authed for destroy
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LOCK PASSWORD (0xB5)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestLockPassword:
|
|
def test_lock_password_prevents_write(self):
|
|
"""LOCK PASSWORD (0xB5) permanently prevents WRITE PASSWORD."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
read_password=0x11111111)
|
|
run(tag.power_on())
|
|
|
|
# Auth and lock the read password
|
|
_authenticate(tag, PWD_READ, 0x11111111)
|
|
cmd = bytes([0x02, 0xB5, 0x04, PWD_READ])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] == 0x00 # success
|
|
|
|
# Re-auth (still works — password itself unchanged)
|
|
_authenticate(tag, PWD_READ, 0x11111111)
|
|
|
|
# WRITE PASSWORD should now fail even though authenticated
|
|
new_xored = _xor_password(tag, 0x22222222)
|
|
cmd = bytes([0x02, 0xB4, 0x04, PWD_READ]) + new_xored
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp.data[0] & 0x01 # error — locked
|
|
|
|
def test_lock_password_requires_auth(self):
|
|
"""LOCK PASSWORD fails without prior auth."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
read_password=0x11111111)
|
|
run(tag.power_on())
|
|
|
|
cmd = bytes([0x02, 0xB5, 0x04, PWD_READ])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] & 0x01 # error
|
|
|
|
def test_lock_password_irreversible(self):
|
|
"""Once locked, password stays locked even after re-auth."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
write_password=0xAAAAAAAA)
|
|
run(tag.power_on())
|
|
|
|
_authenticate(tag, PWD_WRITE, 0xAAAAAAAA)
|
|
cmd = bytes([0x02, 0xB5, 0x04, PWD_WRITE])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp.data[0] == 0x00
|
|
|
|
# Re-auth and try lock again — should still be locked (idempotent ok)
|
|
_authenticate(tag, PWD_WRITE, 0xAAAAAAAA)
|
|
assert PWD_WRITE in tag._locked_passwords
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LOCK PAGE PROTECTION (0xB7)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestLockPageProtection:
|
|
def test_lock_page_protection(self):
|
|
"""LOCK PAGE PROTECTION (0xB7) permanently locks PP config."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
write_password=0x12345678,
|
|
protection_pointer=8)
|
|
run(tag.power_on())
|
|
|
|
_authenticate(tag, PWD_WRITE, 0x12345678)
|
|
|
|
cmd = bytes([0x02, 0xB7, 0x04])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] == 0x00 # success
|
|
|
|
# PROTECT PAGE should now fail
|
|
_authenticate(tag, PWD_WRITE, 0x12345678)
|
|
cmd = bytes([0x02, 0xB6, 0x04, 0x04, 0x01])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp.data[0] & 0x01 # error — locked
|
|
|
|
def test_lock_page_protection_requires_auth(self):
|
|
"""LOCK PAGE PROTECTION fails without write auth."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
write_password=0x12345678)
|
|
run(tag.power_on())
|
|
|
|
cmd = bytes([0x02, 0xB7, 0x04])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] & 0x01
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DESTROY (0xB9)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestDestroy:
|
|
def test_destroy_kills_tag(self):
|
|
"""DESTROY (0xB9) permanently kills tag — no more responses."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
destroy_password=0xDEAD1234)
|
|
run(tag.power_on())
|
|
|
|
xored = _xor_password(tag, 0xDEAD1234)
|
|
cmd = bytes([0x02, 0xB9, 0x04]) + xored
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] == 0x00 # success
|
|
|
|
# Tag should not respond to anything now
|
|
inv = bytes([0x26, 0x01, 0x00])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(inv)))
|
|
assert resp is None
|
|
|
|
def test_destroy_wrong_password(self):
|
|
"""DESTROY with wrong password fails, tag still alive."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
destroy_password=0xDEAD1234)
|
|
run(tag.power_on())
|
|
|
|
xored = _xor_password(tag, 0x00000000) # wrong
|
|
cmd = bytes([0x02, 0xB9, 0x04]) + xored
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] & 0x01 # error
|
|
|
|
# Tag still alive
|
|
inv = bytes([0x26, 0x01, 0x00])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(inv)))
|
|
assert resp is not None
|
|
|
|
def test_destroy_no_password_configured(self):
|
|
"""DESTROY fails if no destroy password configured."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5))
|
|
run(tag.power_on())
|
|
|
|
xored = _xor_password(tag, 0x00000000)
|
|
cmd = bytes([0x02, 0xB9, 0x04]) + xored
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] & 0x01
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 64-BIT PASSWORD PROTECTION (0xBB)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestPasswordProtection64Bit:
|
|
def test_64bit_requires_both_passwords(self):
|
|
"""64-BIT PASSWORD PROTECTION (0xBB) needs both read + write auth."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
read_password=0x11111111,
|
|
write_password=0x22222222,
|
|
protection_pointer=4)
|
|
run(tag.power_on())
|
|
|
|
# Auth both
|
|
_authenticate(tag, PWD_READ, 0x11111111)
|
|
_authenticate(tag, PWD_WRITE, 0x22222222)
|
|
|
|
cmd = bytes([0x02, 0xBB, 0x04])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] == 0x00 # success
|
|
assert tag._extended_protection
|
|
|
|
def test_64bit_fails_without_read_auth(self):
|
|
"""64-BIT PASSWORD PROTECTION fails with only write auth."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
read_password=0x11111111,
|
|
write_password=0x22222222)
|
|
run(tag.power_on())
|
|
|
|
_authenticate(tag, PWD_WRITE, 0x22222222)
|
|
|
|
cmd = bytes([0x02, 0xBB, 0x04])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] & 0x01
|
|
|
|
def test_64bit_fails_without_write_auth(self):
|
|
"""64-BIT PASSWORD PROTECTION fails with only read auth."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
read_password=0x11111111,
|
|
write_password=0x22222222)
|
|
run(tag.power_on())
|
|
|
|
_authenticate(tag, PWD_READ, 0x11111111)
|
|
|
|
cmd = bytes([0x02, 0xBB, 0x04])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] & 0x01
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SET EAS (0xA2) / RESET EAS (0xA3)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSetResetEas:
|
|
def test_set_eas_with_auth(self):
|
|
"""SET EAS (0xA2) enables EAS after EAS/AFI password auth."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
eas_afi_password=0xAABBCCDD)
|
|
run(tag.power_on())
|
|
assert not tag.eas_enabled
|
|
|
|
_authenticate(tag, PWD_EAS_AFI, 0xAABBCCDD)
|
|
cmd = bytes([0x02, CMD_SET_EAS, 0x04])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] == 0x00
|
|
assert tag.eas_enabled
|
|
|
|
def test_set_eas_requires_auth_when_protected(self):
|
|
"""SET EAS fails without auth when password protection is enabled."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
eas_afi_password=0xAABBCCDD)
|
|
run(tag.power_on())
|
|
tag._eas_password_protected = True
|
|
|
|
cmd = bytes([0x02, CMD_SET_EAS, 0x04])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] & 0x01
|
|
|
|
def test_set_eas_without_password(self):
|
|
"""SET EAS succeeds without auth when no password protection enabled."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5))
|
|
run(tag.power_on())
|
|
|
|
cmd = bytes([0x02, CMD_SET_EAS, 0x04])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] == 0x00
|
|
assert tag.eas_enabled
|
|
|
|
def test_reset_eas(self):
|
|
"""RESET EAS (0xA3) disables EAS."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
eas_afi_password=0xAABBCCDD)
|
|
run(tag.power_on())
|
|
tag.set_eas(True)
|
|
assert tag.eas_enabled
|
|
|
|
_authenticate(tag, PWD_EAS_AFI, 0xAABBCCDD)
|
|
cmd = bytes([0x02, CMD_RESET_EAS, 0x04])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] == 0x00
|
|
assert not tag.eas_enabled
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LOCK EAS (0xA4)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestLockEas:
|
|
def test_lock_eas_prevents_changes(self):
|
|
"""LOCK EAS (0xA4) permanently locks EAS state."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
eas_afi_password=0xAABBCCDD)
|
|
run(tag.power_on())
|
|
tag.set_eas(True)
|
|
|
|
_authenticate(tag, PWD_EAS_AFI, 0xAABBCCDD)
|
|
cmd = bytes([0x02, CMD_LOCK_EAS, 0x04])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] == 0x00
|
|
|
|
# SET EAS / RESET EAS should now fail
|
|
_authenticate(tag, PWD_EAS_AFI, 0xAABBCCDD)
|
|
cmd = bytes([0x02, CMD_RESET_EAS, 0x04])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp.data[0] & 0x01
|
|
assert tag.eas_enabled # still enabled — locked
|
|
|
|
def test_lock_eas_requires_auth(self):
|
|
"""LOCK EAS fails without EAS/AFI auth when protected."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
eas_afi_password=0xAABBCCDD)
|
|
run(tag.power_on())
|
|
tag._eas_password_protected = True
|
|
|
|
cmd = bytes([0x02, CMD_LOCK_EAS, 0x04])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] & 0x01
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# EAS ALARM (0xA5)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestEasAlarm:
|
|
def test_eas_alarm_when_enabled(self):
|
|
"""EAS ALARM (0xA5) returns EAS sequence when EAS is enabled."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5))
|
|
run(tag.power_on())
|
|
tag.set_eas(True)
|
|
|
|
cmd = bytes([0x02, CMD_EAS_ALARM, 0x04])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] == 0x00
|
|
assert len(resp.data) >= 33 # flags + 32-byte EAS sequence
|
|
|
|
def test_eas_alarm_when_disabled(self):
|
|
"""EAS ALARM returns no response when EAS is disabled."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5))
|
|
run(tag.power_on())
|
|
assert not tag.eas_enabled
|
|
|
|
cmd = bytes([0x02, CMD_EAS_ALARM, 0x04])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is None
|
|
|
|
def test_eas_alarm_custom_id(self):
|
|
"""EAS ALARM includes EAS ID when set."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5))
|
|
run(tag.power_on())
|
|
tag.set_eas(True)
|
|
tag._eas_id = 0x1234
|
|
|
|
cmd = bytes([0x02, CMD_EAS_ALARM, 0x04])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] == 0x00
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PASSWORD PROTECT EAS/AFI (0xA6)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestPasswordProtectEasAfi:
|
|
def test_enable_eas_afi_protection(self):
|
|
"""PASSWORD PROTECT EAS/AFI (0xA6) enables password protection."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
eas_afi_password=0xAABBCCDD)
|
|
run(tag.power_on())
|
|
assert not tag._eas_password_protected
|
|
|
|
_authenticate(tag, PWD_EAS_AFI, 0xAABBCCDD)
|
|
cmd = bytes([0x02, CMD_PASSWORD_PROTECT_EAS_AFI, 0x04])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] == 0x00
|
|
assert tag._eas_password_protected
|
|
|
|
def test_eas_afi_protection_requires_auth(self):
|
|
"""PASSWORD PROTECT EAS/AFI fails without auth."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
eas_afi_password=0xAABBCCDD)
|
|
run(tag.power_on())
|
|
|
|
cmd = bytes([0x02, CMD_PASSWORD_PROTECT_EAS_AFI, 0x04])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] & 0x01
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WRITE EAS ID (0xA7)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestWriteEasId:
|
|
def test_write_eas_id(self):
|
|
"""WRITE EAS ID (0xA7) sets 16-bit EAS identifier."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
eas_afi_password=0xAABBCCDD)
|
|
run(tag.power_on())
|
|
|
|
_authenticate(tag, PWD_EAS_AFI, 0xAABBCCDD)
|
|
cmd = bytes([0x02, CMD_WRITE_EAS_ID, 0x04, 0x34, 0x12])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] == 0x00
|
|
assert tag._eas_id == 0x1234
|
|
|
|
def test_write_eas_id_requires_auth_when_protected(self):
|
|
"""WRITE EAS ID fails without auth when protected."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
eas_afi_password=0xAABBCCDD)
|
|
run(tag.power_on())
|
|
tag._eas_password_protected = True
|
|
|
|
cmd = bytes([0x02, CMD_WRITE_EAS_ID, 0x04, 0x34, 0x12])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] & 0x01
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# STAY QUIET PERSISTENT (0xBC)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestStayQuietPersistent:
|
|
@staticmethod
|
|
def _sqp_cmd(uid: bytes) -> bytes:
|
|
"""Build addressed STAY QUIET PERSISTENT command."""
|
|
# flags=0x22 (addressed + high data rate), cmd=0xBC, UID reversed, mfg=0x04
|
|
return bytes([0x22, 0xBC]) + uid[::-1] + bytes([0x04])
|
|
|
|
def test_stay_quiet_persistent(self):
|
|
"""STAY QUIET PERSISTENT (0xBC) silences tag persistently."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5))
|
|
run(tag.power_on())
|
|
|
|
cmd = self._sqp_cmd(tag._uid)
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] == 0x00
|
|
|
|
# Tag should not respond to inventory
|
|
inv = bytes([0x26, 0x01, 0x00])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(inv)))
|
|
assert resp is None
|
|
|
|
def test_stay_quiet_persistent_survives_power_cycle(self):
|
|
"""STAY QUIET PERSISTENT survives power-off/on."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5))
|
|
run(tag.power_on())
|
|
|
|
cmd = self._sqp_cmd(tag._uid)
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp.data[0] == 0x00
|
|
|
|
# Simulate power cycle
|
|
run(tag.power_off())
|
|
run(tag.power_on())
|
|
|
|
# Still quiet
|
|
inv = bytes([0x26, 0x01, 0x00])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(inv)))
|
|
assert resp is None
|
|
|
|
def test_stay_quiet_persistent_reset_by_password(self):
|
|
"""SET_PASSWORD still works even in persistent quiet (addressed)."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
privacy_password=0xDEADBEEF)
|
|
run(tag.power_on())
|
|
|
|
cmd = self._sqp_cmd(tag._uid)
|
|
run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
|
|
# SET_PASSWORD should still work (unaddressed custom cmds still handled)
|
|
xored = _xor_password(tag, 0xDEADBEEF)
|
|
cmd = bytes([0x02, 0xB3, 0x04, PWD_PRIVACY]) + xored
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] == 0x00
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# INVENTORY READ (0xA0) / FAST INVENTORY READ (0xA1)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestInventoryRead:
|
|
def test_inventory_read_returns_data(self):
|
|
"""INVENTORY READ (0xA0) returns UID + block data."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02\x01\x03\x04\x05\x06",
|
|
block_size=4, num_blocks=28)
|
|
run(tag.power_on())
|
|
|
|
# Write known data to block 0
|
|
write_cmd = bytes([0x02, 0x21, 0x00, 0xAA, 0xBB, 0xCC, 0xDD])
|
|
run(tag.handle_frame(RFFrame.from_bytes(write_cmd)))
|
|
|
|
# INVENTORY READ: flags(1) + cmd(1) + mfg(1) + mask_len(1) + first_block(1) + num_blocks(1)
|
|
cmd = bytes([0x06, CMD_INVENTORY_READ, 0x04, 0x00, 0x00, 0x00])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] == 0x00 # success flags
|
|
# Response contains DSFID + UID + block data
|
|
assert len(resp.data) > 10
|
|
|
|
def test_fast_inventory_read(self):
|
|
"""FAST INVENTORY READ (0xA1) same behavior as INVENTORY READ."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02\x01\x03\x04\x05\x06",
|
|
block_size=4, num_blocks=28)
|
|
run(tag.power_on())
|
|
|
|
cmd = bytes([0x06, CMD_FAST_INVENTORY_READ, 0x04, 0x00, 0x00, 0x00])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] == 0x00
|
|
|
|
def test_inventory_read_specific_blocks(self):
|
|
"""INVENTORY READ reads specific block range."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02\x01\x03\x04\x05\x06",
|
|
block_size=4, num_blocks=28)
|
|
run(tag.power_on())
|
|
|
|
# Write known data
|
|
write_cmd = bytes([0x02, 0x21, 0x02, 0x11, 0x22, 0x33, 0x44])
|
|
run(tag.handle_frame(RFFrame.from_bytes(write_cmd)))
|
|
|
|
# Read block 2, 1 block
|
|
cmd = bytes([0x06, CMD_INVENTORY_READ, 0x04, 0x00, 0x02, 0x00])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
# Response should contain the written data
|
|
assert b"\x11\x22\x33\x44" in resp.data
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Block 79 counter (increment-only write semantics)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestBlock79Counter:
|
|
def test_counter_increments(self):
|
|
"""WRITE to block 79 increments the 16-bit counter, not overwrites."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
block_size=4, num_blocks=80)
|
|
run(tag.power_on())
|
|
|
|
# Write increment value of 1
|
|
cmd = bytes([0x02, 0x21, 79, 0x01, 0x00, 0x00, 0x00])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] == 0x00
|
|
|
|
# Read back — counter should be 1
|
|
cmd = bytes([0x02, 0x20, 79])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
counter = struct.unpack_from("<H", resp.data, 1)[0]
|
|
assert counter == 1
|
|
|
|
def test_counter_accumulates(self):
|
|
"""Multiple writes to block 79 accumulate."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
block_size=4, num_blocks=80)
|
|
run(tag.power_on())
|
|
|
|
for _ in range(5):
|
|
cmd = bytes([0x02, 0x21, 79, 0x01, 0x00, 0x00, 0x00])
|
|
run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
|
|
cmd = bytes([0x02, 0x20, 79])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
counter = struct.unpack_from("<H", resp.data, 1)[0]
|
|
assert counter == 5
|
|
|
|
def test_counter_saturates_at_max(self):
|
|
"""Counter saturates at 0xFFFF, does not wrap."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
block_size=4, num_blocks=80)
|
|
run(tag.power_on())
|
|
|
|
# Set counter close to max via direct memory
|
|
offset = 79 * 4
|
|
tag._memory[offset:offset + 2] = struct.pack("<H", 0xFFFE)
|
|
|
|
# Increment by 1 → 0xFFFF
|
|
cmd = bytes([0x02, 0x21, 79, 0x01, 0x00, 0x00, 0x00])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp.data[0] == 0x00
|
|
|
|
# Increment again — should stay at 0xFFFF
|
|
cmd = bytes([0x02, 0x21, 79, 0x01, 0x00, 0x00, 0x00])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp.data[0] == 0x00
|
|
|
|
cmd = bytes([0x02, 0x20, 79])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
counter = struct.unpack_from("<H", resp.data, 1)[0]
|
|
assert counter == 0xFFFF
|
|
|
|
def test_normal_blocks_still_overwrite(self):
|
|
"""Non-counter blocks still use normal overwrite semantics."""
|
|
tag = IcodeSlix2Tag(uid=b"\xE0\x04\x02" + bytes(5),
|
|
block_size=4, num_blocks=80)
|
|
run(tag.power_on())
|
|
|
|
# Write to block 0 twice — should overwrite, not increment
|
|
cmd = bytes([0x02, 0x21, 0x00, 0xAA, 0xBB, 0xCC, 0xDD])
|
|
run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
cmd = bytes([0x02, 0x21, 0x00, 0x11, 0x22, 0x33, 0x44])
|
|
run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
|
|
cmd = bytes([0x02, 0x20, 0x00])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp.data[1:5] == bytes([0x11, 0x22, 0x33, 0x44])
|