Files
pm3py/tests/test_bitfield.py
michael f8d01a9120 feat(registers): NTAG5 STATUS_REG / CONFIG_REG self-describing views
Add Ntag5StatusReg and Ntag5ConfigReg (Register subclasses) and bind
them as tag.status_reg / tag.config_reg on the NTAG 5 platform. Purely
additive: every existing per-field accessor (disable_nfc, arbiter_mode,
sram_enable, ...) stays, and the config_reg setter re-syncs the
_arbiter_mode / _sram_enabled sibling caches the per-field setters keep,
so the sim internals are untouched.

- Ntag5StatusReg (0xA0): field/VCC/boot/lock/EEPROM/SRAM status bits
- Ntag5ConfigReg (0xA1): disable_nfc, arbiter_mode (normal/mirror/
  pass-through/PHDC), sram_enable, config_pt_transfer_dir

Bit layout taken straight from the existing hand-rolled accessors, so
the register view decodes the very same bytes (cross-checked in tests).
5 new tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 08:54:24 -07:00

434 lines
16 KiB
Python

"""Bit-field register infrastructure + the T5577 config register. Hardware-free."""
import pytest
from pm3py.transponders.bitfield import BitField, Register
from pm3py.sim import (
T5577Config, T5577Tag,
Ntag21xConfig, NTAG213, MF0UL21, MifareUltralightC,
MifareAccessConditions, MifareClassicTag,
EM4100Code, EM4100Tag,
NtagI2CConfig, NtagI2CSession, NtagI2CPtI2C, NT3H2111, NT3H2211,
Ntag5StatusReg, Ntag5ConfigReg, Ntag5LinkTag,
)
class Demo(Register):
_width_bits = 16
_label = "demo"
top = BitField(15, 12, doc="top nibble")
flag = BitField(3, doc="a flag")
mode = BitField(1, 0, values={0: "a", 1: "b", 2: "c"}, doc="a labelled mode")
class TestBitFieldCore:
def test_read_fields(self):
d = Demo(0xF008) # top=0xF, flag(bit3)=1, mode=0
assert d.top == 0xF
assert d.flag is True
assert d.mode == "a"
def test_single_bit_is_bool(self):
assert Demo(0).flag is False
assert Demo(0x8).flag is True
def test_write_by_field_is_isolated(self):
d = Demo(0)
d.top = 0xA
assert d.value == 0xA000
d.flag = True
assert d.value == 0xA008
d.top = 0x5 # must not disturb the flag bit
assert d.value == 0x5008
def test_values_map_both_directions(self):
d = Demo(0)
d.mode = "c"
assert d.mode == "c" and d.value == 0x0002
d.mode = 1 # raw code still accepted
assert d.mode == "b"
def test_overflow_rejected(self):
with pytest.raises(ValueError):
Demo(0).top = 0x1F # 5 bits into a 4-bit field
def test_int_and_index(self):
d = Demo(0x1234)
assert int(d) == 0x1234
assert hex(d) == "0x1234"
def test_fields_ordered_high_to_low(self):
assert [n for n, _ in Demo.fields()] == ["top", "flag", "mode"]
def test_descriptor_doc_visible(self):
# the completion tooltip / `field?` help comes from the descriptor docstring
assert Demo.top.__doc__ == "top nibble"
# The three anchor constants below are lifted from the Proxmark3 config-block table
# (client/src/cmdlft55xx.h) with their documented meanings — they pin the bit layout.
class TestT5577Decode:
def test_em4100(self):
cfg = T5577Config(0x00148040)
assert cfg.modulation == "ASK"
assert cfg.data_bit_rate == 64
assert cfg.max_block == 2
assert cfg.x_mode is False
assert cfg.st is False
assert cfg.master_key == "0 (normal)"
def test_default_ask_manchester(self):
cfg = T5577Config(0x000880E8) # ASK, RF/32, STT, 7 data blocks
assert cfg.modulation == "ASK"
assert cfg.data_bit_rate == 32
assert cfg.max_block == 7
assert cfg.st is True
def test_pac_is_nrz(self):
cfg = T5577Config(0x00080080) # NRZ, RF/32, 4 data blocks
assert cfg.modulation == "direct"
assert cfg.max_block == 4
def test_hid_fsk(self):
cfg = T5577Config(0x00107060) # FSK2a, RF/50, 3 blocks
assert cfg.modulation == "FSK2a"
assert cfg.data_bit_rate == 50
assert cfg.max_block == 3
def test_xmode_data_rate(self):
cfg = T5577Config(0x903F0082) # fdx-b, x-mode
assert cfg.x_mode is True
assert cfg.modulation == "biphase"
assert cfg.data_bit_rate == 32 # 2*code+2 over the 6-bit field
class TestT5577Build:
def test_preset_roundtrip(self):
assert int(T5577Config.preset("em4100")) == 0x00148040
def test_unknown_preset(self):
with pytest.raises(ValueError):
T5577Config.preset("nope")
def test_edit_by_field(self):
cfg = T5577Config.preset("em4100")
cfg.max_block = 3
cfg.st = True
# max_block bits 7:5 -> 3<<5 = 0x60; st bit3 -> 0x08; base had maxblk 2 (0x40)
assert cfg.max_block == 3 and cfg.st is True
assert int(cfg) == (0x00148040 & ~(0x07 << 5)) | (3 << 5) | (1 << 3)
def test_set_data_rate_standard(self):
cfg = T5577Config(0x00148040)
cfg.data_bit_rate = 32
assert cfg.data_bit_rate == 32
with pytest.raises(ValueError):
cfg.data_bit_rate = 33 # not a legal standard divider
def test_set_modulation_by_name(self):
cfg = T5577Config(0)
cfg.modulation = "FSK2"
assert cfg.modulation == "FSK2"
assert cfg.modulation != "ASK"
def test_decode_mapping_and_repr(self):
cfg = T5577Config(0x00148040)
d = cfg.decode()
assert d["modulation"] == "ASK"
assert d["data_bit_rate"] == "RF/64"
assert d["max_block"] == "2"
text = repr(cfg) # not a tty in tests -> plain text
assert "T5577 config (block0) = 0x00148040" in text
assert "modulation" in text and "ASK" in text
assert "data_bit_rate" in text and "RF/64" in text
class TestT5577TagBinding:
def test_config_reads_block0(self):
tag = T5577Tag()
tag.blocks[0] = 0x00148040
assert tag.config.modulation == "ASK"
assert tag.config.max_block == 2
def test_assign_config_programs_block0(self):
tag = T5577Tag()
cfg = T5577Config.preset("hid")
tag.config = cfg
assert tag.blocks[0] == 0x00107060
# accepts a raw int too
tag.config = 0x00148040
assert tag.config.modulation == "ASK"
class TestNtag21xConfig:
def test_fields_span_both_pages(self):
cfg = Ntag21xConfig(0)
cfg.auth0 = 0x04 # CFG0 byte 3
cfg.prot = True # CFG1 byte 0 bit 7 (ACCESS.PROT)
# CFG0 = 00 00 00 04, CFG1 = 80 00 00 00
assert bytes(cfg) == b"\x00\x00\x00\x04\x80\x00\x00\x00"
assert cfg.auth0 == 0x04 and cfg.prot is True
def test_mirror_and_counter_fields(self):
cfg = Ntag21xConfig(0)
cfg.mirror_conf = "UID+counter"
cfg.mirror_page = 0x10
cfg.nfc_cnt_en = True
cfg.authlim = 5
assert cfg.mirror_conf == "UID+counter"
assert cfg.mirror_page == 0x10
assert cfg.nfc_cnt_en is True and cfg.authlim == 5
# re-decoding the raw word must reproduce everything
assert Ntag21xConfig(int(cfg)).decode() == cfg.decode()
def test_tag_binding_roundtrip(self):
tag = NTAG213() # fresh: auth0=0xFF, prot off
assert tag.config.auth0 == 0xFF
assert tag.config.prot is False
cfg = tag.config
cfg.auth0 = 0x10
cfg.prot = True
tag.config = cfg # program both config pages
assert tag.config.auth0 == 0x10 and tag.config.prot is True
class TestUltralightConfig:
def test_ev1_uses_shared_fields(self):
tag = MF0UL21()
cfg = tag.config
cfg.auth0 = 0x08
cfg.cfglck = True
tag.config = cfg
assert tag.config.auth0 == 0x08 and tag.config.cfglck is True
assert "NTAG21x" not in repr(cfg) # EV1 has no mirror/counter fields
def test_ulc_auth_config(self):
tag = MifareUltralightC(auth0=0x30, auth1_read_protect=True)
cfg = tag.config
assert cfg.auth0 == 0x30
assert cfg.auth1_prot == "read+write" # AUTH1 bit0 == 0
cfg.auth1_prot = "write-only"
cfg.auth0 = 0x2A
tag.config = cfg
assert tag.config.auth0 == 0x2A
assert tag.config.auth1_prot == "write-only"
class TestMifareAccessConditions:
def test_factory_default_trailer(self):
# FF 07 80 access bytes + GPB 0x69 = the transport/default trailer
ac = MifareAccessConditions(0xFF078069)
assert ac.valid is True
assert ac.block0 == 0 and ac.block1 == 0 and ac.block2 == 0
assert ac.trailer == 1
assert ac.gpb == 0x69
def test_build_matches_default(self):
ac = MifareAccessConditions.build(0, 0, 0, trailer=1, gpb=0x69)
assert int(ac) == 0xFF078069
assert bytes(ac) == b"\xFF\x07\x80\x69" # trailer bytes 6..9
def test_build_is_always_valid(self):
for t in range(8):
for d in range(8):
assert MifareAccessConditions.build(d, d, d, trailer=t).valid
def test_all_locked(self):
ac = MifareAccessConditions.build(7, 7, 7, trailer=7)
assert ac.trailer == 7 and ac.block0 == 7
assert "no access" in ac.describe(0)
assert "locked" in ac.describe(3)
def test_inconsistent_copy_flagged(self):
ac = MifareAccessConditions(0x00000000) # C == ~C == 0, impossible
assert ac.valid is False
assert "INVALID" in repr(ac)
def test_bad_condition_rejected(self):
with pytest.raises(ValueError):
MifareAccessConditions.build(trailer=8)
def test_describe_and_decode(self):
ac = MifareAccessConditions(0xFF078069)
assert "transport" in ac.describe(0)
assert "transport/default" in ac.describe(3)
d = ac.decode()
assert d["block0"].startswith("0")
assert d["trailer"].startswith("1")
def test_tag_access_reads_trailer(self):
tag = MifareClassicTag(uid=b"\x01\x02\x03\x04")
# write a full trailer for sector 1: KeyA(6) + FF0780 + GPB69 + KeyB(6)
trailer_blk = tag._sector_trailer_block(1)
trailer = b"\xA0\xA1\xA2\xA3\xA4\xA5" + b"\xFF\x07\x80\x69" + b"\xB0\xB1\xB2\xB3\xB4\xB5"
tag.write_block_raw(trailer_blk, trailer)
ac = tag.access(1)
assert ac.trailer == 1 and ac.gpb == 0x69 and ac.valid
class TestEM4100Code:
def test_build_roundtrip(self):
code = EM4100Code.build(0x0102030405)
assert code.tag_id == 0x0102030405
assert code.customer_id == 0x01
assert code.card_number == 0x02030405
assert code.valid
def test_build_is_always_valid(self):
for tid in (0x0, 0xFFFFFFFFFF, 0x1234567890, 0xDEADBEEF00, 0xA5A5A5A5A5):
assert EM4100Code.build(tid).valid
def test_matches_model_encoding(self):
# the register must agree bit-for-bit with the tag's own encoder
tag = EM4100Tag(0xCAFEBABE99)
assert tag.code.valid
assert tag.code.tag_id == 0xCAFEBABE99
assert int(tag.code) == int(EM4100Code.build(0xCAFEBABE99))
def test_flipped_data_bit_breaks_row_parity(self):
code = EM4100Code.build(0x0102030405)
broken = EM4100Code(int(code) ^ (1 << 40)) # flip a data bit in the rows region
assert broken.valid is False
assert broken._bad_rows() # at least one row parity fails
def test_bad_header_and_stop_detected(self):
good = EM4100Code.build(0)
assert good.valid
assert EM4100Code(int(good) | 1).valid is False # stop bit set
assert EM4100Code(int(good) & ~(0x1FF << 55)).valid is False # header zeroed
def test_repr_shows_id_and_validity(self):
text = repr(EM4100Code.build(0x0102030405))
assert "EM4100 64-bit code" in text
assert "0102030405" in text
assert "valid" in text and "yes" in text
class TestNtagI2CRegisters:
def test_fresh_config_defaults(self):
# NT3H2111 ships E8=01 00 F8 48, E9=08 01 00 00 (datasheet defaults)
cfg = NT3H2111().config
assert cfg.transfer_dir == "NFC->I2C" # NC_REG bit0 = 1
assert cfg.pthru_on is False
assert cfg.sram_mirror_on is False
assert cfg.sram_mirror_block == 0xF8
assert cfg.watchdog == 0x0848
assert cfg.i2c_clock_str is True
assert cfg.reg_lock_i2c is False and cfg.reg_lock_nfc is False
def test_nc_reg_fields_roundtrip(self):
tag = NT3H2111()
cfg = tag.config
cfg.pthru_on = True
cfg.sram_mirror_on = True
cfg.fd_on = "tag selected" # 0b10
cfg.fd_off = "field off / last NDEF read" # 0b10
cfg.last_ndef_block = 0x2A
tag.config = cfg # program E8/E9
back = tag.config
assert back.pthru_on is True and back.sram_mirror_on is True
assert back.fd_on == "tag selected"
assert back.fd_off == "field off / last NDEF read"
assert back.last_ndef_block == 0x2A
def test_reg_lock(self):
cfg = NtagI2CConfig(0)
cfg.reg_lock_nfc = True
# REG_LOCK is byte 6 (bits 15:8); bit0 = NFC lock -> byte6 == 0x01
assert bytes(cfg)[6] == 0x01
assert cfg.reg_lock_nfc is True and cfg.reg_lock_i2c is False
def test_session_ns_reg_decode(self):
# NS_REG is byte 6 of the EC/ED block; set NDEF_DATA_READ + RF_FIELD_PRESENT + busy
sess = NtagI2CSession(0)
assert sess.rf_field_present is False
raw = int(NtagI2CSession(0))
# byte6 bit7 (ndef_data_read)=bit15, bit0 (rf_field_present)=bit8, bit1 (busy)=bit9
sess = NtagI2CSession((1 << 15) | (1 << 8) | (1 << 9))
assert sess.ndef_data_read is True
assert sess.rf_field_present is True
assert sess.eeprom_wr_busy is True
assert sess.rf_locked is False
def test_session_binding(self):
tag = NT3H2111()
sess = tag.session
# session copy shares NC_REG defaults (transfer_dir=1)
assert sess.transfer_dir == "NFC->I2C"
assert sess.rf_field_present is False # NS_REG starts clear
def test_pt_i2c(self):
pt = NtagI2CPtI2C(0)
pt.sram_prot = True
pt.i2c_prot = "protected area read-only" # 0b01, unambiguous
assert pt.sram_prot is True
assert pt.i2c_prot == "protected area read-only"
assert int(pt) == (1 << 2) | (1 << 0)
# datasheet 1Xb == "no access to protected area" for both codes 2 and 3
assert NtagI2CPtI2C(2).i2c_prot == "protected area no access"
assert NtagI2CPtI2C(3).i2c_prot == "protected area no access"
# tag binding
tag = NT3H2211()
tag.pt_i2c = pt
assert tag.pt_i2c.i2c_prot == "protected area read-only"
def test_repr_is_decode_table(self):
text = repr(NT3H2111().config)
assert "NTAG I2C config registers" in text
assert "transfer_dir" in text and "NFC->I2C" in text
assert "sram_mirror_block" in text
class TestNtag5Registers:
def test_config_reg_field_positions(self):
cfg = Ntag5ConfigReg(0)
cfg.arbiter_mode = "pass-through" # 0b10 at byte0 bits 3:2
cfg.sram_enable = True # byte0 bit1
cfg.disable_nfc = True # byte0 bit5
cfg.config_pt_transfer_dir = "NFC->I2C" # byte0 bit0
# byte0 = 0b0010_1011 = 0x2B (disable_nfc bit5 + arb=10 at bit3 + sram bit1 + ptdir bit0)
assert bytes(cfg)[0] == 0x2B
assert cfg.arbiter_mode == "pass-through"
assert cfg.disable_nfc is True
def test_status_reg_decode(self):
# byte0: nfc_field_ok(0)+vcc_supply_ok(1)+eeprom_wr_busy(7); byte1: nfc_if_locked(0)
st = Ntag5StatusReg(int.from_bytes(bytes([0b1000_0011, 0x01, 0, 0]), "big"))
assert st.nfc_field_ok is True
assert st.vcc_supply_ok is True
assert st.eeprom_wr_busy is True
assert st.sram_data_ready is False
assert st.nfc_if_locked is True
def test_view_agrees_with_existing_accessors(self):
tag = Ntag5LinkTag()
tag.arbiter_mode = 2 # existing per-field setter
tag.disable_nfc = True
tag.sram_enable = True
# the register view must decode the very same bytes
assert tag.config_reg.arbiter_mode == "pass-through"
assert tag.config_reg.disable_nfc is True
assert tag.config_reg.sram_enable is True
def test_assign_config_reg_updates_accessors_and_siblings(self):
tag = Ntag5LinkTag()
cfg = Ntag5ConfigReg(0)
cfg.arbiter_mode = "mirror" # 1
cfg.sram_enable = True
tag.config_reg = cfg
# existing property accessors (read straight off the bytes) must reflect it
assert tag.arbiter_mode == 1
assert tag.sram_enable is True
# and the sibling caches the individual setters maintain stay in sync
if hasattr(tag, "_arbiter_mode"):
assert tag._arbiter_mode == 1
if hasattr(tag, "_sram_enabled"):
assert tag._sram_enabled is True
def test_status_reg_binding_roundtrip(self):
tag = Ntag5LinkTag()
st = tag.status_reg
st.i2c_if_locked = True
tag.status_reg = st
assert tag.i2c_if_locked is True
assert tag.status_reg.i2c_if_locked is True