feat(rawcli): finish T5577 catalog — page/password, RESET, hex data words
Round out the ATA5577C command set to the datasheet-catalog standard: - RESET command (downlink opcode 00), backed by a new lf.t55.reset() that drives CMD_LF_T55XX_RESET_READ. Hardware-validated (status 0). - READ/WRITE gain optional <page> (1 = traceability) and <password> for password-mode access, threaded through read_config / read_t55xx_block. - WRITE data and all passwords now parse as hex words (like the pm3 client's -d/-p and every other rawcli WRITE); blocks/pages stay decimal. Previously T5577 WRITE data alone was base-10, so WRITE(0, 00088040) wrote decimal. Live-validated on a T5577 emulating EM4100 00FFFFFFFF: DETECT/READ(0) decode the config 00148040 (ASK/RF64/EM4100), RESET/WAKE return ok. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -460,40 +460,64 @@ _NTAG5 = _catalog(
|
||||
# ---- LF T5577 / ATA5577 (block read/write over the T55xx downlink; executes via lf.t55) ----
|
||||
# build() exists only to expose the downlink opcode for hex/binary completion; execution goes
|
||||
# through run() (the device method), since LF isn't a raw-byte exchange like 14a.
|
||||
def _t55_read(dev, block):
|
||||
b = _int(block)
|
||||
if b == 0: # config block — the reliable one
|
||||
def _word(s) -> int:
|
||||
"""A 32-bit T55xx data/password word — parsed as hex (0x/spaces tolerated), matching the pm3
|
||||
client's ``-d``/``-p`` and every other rawcli WRITE. Blocks/pages stay decimal (``_int``)."""
|
||||
h = str(s).lower().replace("0x", "").replace(" ", "") or "0"
|
||||
return int(h, 16) & 0xFFFFFFFF
|
||||
|
||||
|
||||
def _optpwd(password) -> int | None:
|
||||
"""A T55xx password argument (hex word), or None when omitted (the empty-string default)."""
|
||||
return _word(password) if str(password) != "" else None
|
||||
|
||||
|
||||
def _pwd_note(password) -> str:
|
||||
return f" (pwd {_word(password):08X})" if str(password) != "" else ""
|
||||
|
||||
|
||||
def _t55_read(dev, block, page="0", password=""):
|
||||
b, pg, pwd = _int(block), _int(page), _optpwd(password)
|
||||
if b == 0 and pg == 0: # config block — the reliable one
|
||||
from pm3py.lf.capture import read_config
|
||||
r = read_config(dev)
|
||||
r = read_config(dev, password=pwd)
|
||||
if r:
|
||||
return (f"block 0 = {r['config_hex']} ({r['modulation']}, RF/{r['data_bit_rate']}, "
|
||||
f"{r['max_block']} blocks) -> {r['emulating']}")
|
||||
return "READ block 0: no clean config recovered"
|
||||
f"{r['max_block']} blocks) -> {r['emulating']}{_pwd_note(password)}")
|
||||
return f"READ block 0: no clean config recovered{_pwd_note(password)}"
|
||||
from pm3py.lf.capture import read_t55xx_block
|
||||
word = read_t55xx_block(dev, b)
|
||||
word = read_t55xx_block(dev, b, page=pg, password=pwd)
|
||||
where = f"block {b}" + (" (page 1)" if pg else "")
|
||||
if word is None:
|
||||
return f"READ block {b}: no clean response (LF block reads are demod-dependent)"
|
||||
return f"block {b} = {word:08X} (best-effort — bit alignment not verified)"
|
||||
return f"READ {where}: no clean response (LF block reads are demod-dependent){_pwd_note(password)}"
|
||||
return f"{where} = {word:08X} (best-effort — bit alignment not verified){_pwd_note(password)}"
|
||||
|
||||
|
||||
def _t55_write(dev, block, data):
|
||||
r = dev.lf.t55.writebl(_int(block), _int(data))
|
||||
def _t55_write(dev, block, data, page="0", password=""):
|
||||
w = _word(data)
|
||||
r = dev.lf.t55.writebl(_int(block), w, page=_int(page), password=_optpwd(password))
|
||||
ok = isinstance(r, dict) and r.get("status") == 0
|
||||
return f"WRITE block {_int(block)} <- {_int(data):08X}: {'ok' if ok else 'failed'}"
|
||||
where = f"block {_int(block)}" + (" (page 1)" if _int(page) else "")
|
||||
return f"WRITE {where} <- {w:08X}: {'ok' if ok else 'failed'}{_pwd_note(password)}"
|
||||
|
||||
|
||||
def _t55_wake(dev, password):
|
||||
r = dev.lf.t55.wakeup(_int(password))
|
||||
r = dev.lf.t55.wakeup(_word(password))
|
||||
return f"WAKE: {'ok' if isinstance(r, dict) and r.get('status') == 0 else 'failed'}"
|
||||
|
||||
|
||||
def _t55_detect(dev):
|
||||
def _t55_reset(dev):
|
||||
r = dev.lf.t55.reset()
|
||||
return f"RESET: {'ok' if isinstance(r, dict) and r.get('status') == 0 else 'failed'}"
|
||||
|
||||
|
||||
def _t55_detect(dev, password=""):
|
||||
from pm3py.lf.capture import read_config
|
||||
r = read_config(dev)
|
||||
r = read_config(dev, password=_optpwd(password))
|
||||
if not r:
|
||||
return "DETECT: no T55xx config recovered"
|
||||
return f"DETECT: no T55xx config recovered{_pwd_note(password)}"
|
||||
return (f"config {r['config_hex']} ({r['modulation']}, RF/{r['data_bit_rate']}, "
|
||||
f"{r['max_block']} blocks) -> {r['emulating']}")
|
||||
f"{r['max_block']} blocks) -> {r['emulating']}{_pwd_note(password)}")
|
||||
|
||||
|
||||
def _lf_reread(dev):
|
||||
@@ -502,16 +526,26 @@ def _lf_reread(dev):
|
||||
return r["label"] if r else "no LF credential decoded"
|
||||
|
||||
|
||||
# The ATA5577C downlink command set. The wire opcode is a 2-bit field (10/11 = read/write page
|
||||
# 0/1, 00 = reset) followed by optional 32-bit password, lock bit, data and 3-bit address — a
|
||||
# bit-level frame, not a byte exchange, so build() returns a synthetic opcode byte only for the
|
||||
# hex/binary completion; execution goes through run() (lf.t55). READ/WRITE take an optional <page>
|
||||
# (1 = traceability data) and <password> for password-mode access.
|
||||
T5577 = _catalog(
|
||||
"T5577 / ATA5577", "lf",
|
||||
_c("READ", ["block"], build=lambda block: bytes([0x01, _int(block)]), run=_t55_read,
|
||||
help="read a 32-bit block (0=config, 7=password, 1-6=data) — best-effort demod"),
|
||||
_c("WRITE", ["block", "data"], build=lambda block, data: bytes([0x02, _int(block)]),
|
||||
run=_t55_write, help="write 32-bit <data> to <block> (0x02)"),
|
||||
_c("READ", ["block", "page", "password"],
|
||||
build=lambda block, page="0", password="": bytes([0x01, _int(block)]), run=_t55_read,
|
||||
help="read a 32-bit block (0=config, 7=password, 1-6=data); <page> 1 = traceability, "
|
||||
"optional <password> — best-effort demod"),
|
||||
_c("WRITE", ["block", "data", "page", "password"],
|
||||
build=lambda block, data, page="0", password="": bytes([0x02, _int(block)]),
|
||||
run=_t55_write, help="write 32-bit <data> to <block>; optional <page> / <password> (0x02)"),
|
||||
_c("WAKE", ["password"], build=lambda password: bytes([0x03]), run=_t55_wake,
|
||||
help="wake a password-protected tag with the block-7 password (0x03)"),
|
||||
_c("DETECT", [], build=lambda: bytes([0x01, 0x00]), run=_t55_detect,
|
||||
help="read + decode the config block (block 0)"),
|
||||
help="wake a password-protected tag with the block-7 password — AOR (0x03)"),
|
||||
_c("RESET", [], build=lambda: bytes([0x00]), run=_t55_reset,
|
||||
help="send the reset command (opcode 00) — realign the tag's bitstream"),
|
||||
_c("DETECT", ["password"], build=lambda password="": bytes([0x01, 0x00]), run=_t55_detect,
|
||||
help="read + decode the config block (block 0); optional <password>"),
|
||||
)
|
||||
|
||||
# ---- LF read-only credentials (EM4100/HID/AWID/FDX-B): broadcast tags with no command set ----
|
||||
|
||||
@@ -48,6 +48,18 @@ class T55xxCommands:
|
||||
resp = await self._t.send_ng(Cmd.LF_T55XX_WAKEUP, payload)
|
||||
return {"status": resp.status}
|
||||
|
||||
async def reset(self, downlink_mode: int = 0) -> dict:
|
||||
"""Send the T55xx reset command (downlink opcode 00) and read the stream.
|
||||
|
||||
Resets the tag's modem to the start of its bitstream so a subsequent read starts
|
||||
aligned. The firmware (T55xxResetRead) takes a single flags byte carrying the
|
||||
downlink mode; the captured stream lands in BigBuf.
|
||||
"""
|
||||
flags = (downlink_mode & 0x03) << 3
|
||||
payload = struct.pack("<B", flags)
|
||||
resp = await self._t.send_ng(Cmd.LF_T55XX_RESET_READ, payload)
|
||||
return {"status": resp.status}
|
||||
|
||||
# Default T55xx downlink-mode timing table (generic/PM3 Easy build), stored
|
||||
# as raw struct values (field-clocks x 8). Mirrors T55xx_Timing in
|
||||
# armsrc/lfops.c. Firmware exposes no GET, and CMD_LF_T55XX_SET_CONFIG is
|
||||
|
||||
@@ -108,19 +108,22 @@ def read_emitted(device) -> dict | None:
|
||||
return demod_samples(data) if data else None
|
||||
|
||||
|
||||
def read_config(device) -> dict | None:
|
||||
def read_config(device, password: int | None = None) -> dict | None:
|
||||
"""Probe for a T55xx by reading config block 0 and demodulating the response. A clean,
|
||||
sane config both proves the chip is a T5577 and says what it's programmed to emulate."""
|
||||
data = _capture(device, lambda: _try(lambda: device.lf.t55.readbl(0), None))
|
||||
sane config both proves the chip is a T5577 and says what it's programmed to emulate.
|
||||
``password`` (when set) reads a password-protected tag in password mode."""
|
||||
data = _capture(device, lambda: _try(lambda: device.lf.t55.readbl(0, password=password), None))
|
||||
return protocols.decode_t55xx_config(data) if data else None
|
||||
|
||||
|
||||
def read_t55xx_block(device, block: int) -> int | None:
|
||||
def read_t55xx_block(device, block: int, page: int = 0, password: int | None = None) -> int | None:
|
||||
"""Best-effort read of a T55xx 32-bit data block: send the block read, demod the first
|
||||
repeating 32-bit word. A bare block has no header/CRC, so the bit alignment (rotation) can't
|
||||
be verified — this is inherently best-effort. Block 0 (config) is the reliable one; read it
|
||||
via read_config, which resolves the rotation against known presets."""
|
||||
data = _capture(device, lambda: _try(lambda: device.lf.t55.readbl(int(block)), None))
|
||||
via read_config, which resolves the rotation against known presets. ``page`` 1 reaches the
|
||||
traceability data; ``password`` reads a password-protected tag in password mode."""
|
||||
data = _capture(device, lambda: _try(
|
||||
lambda: device.lf.t55.readbl(int(block), page=int(page), password=password), None))
|
||||
if not data:
|
||||
return None
|
||||
for bits in ask.manchester_bits(data):
|
||||
|
||||
@@ -69,6 +69,16 @@ def test_lf_t55_wakeup():
|
||||
assert pwd == 0x11223344
|
||||
assert flags == (3 << 3)
|
||||
|
||||
def test_lf_t55_reset():
|
||||
t = AsyncMock()
|
||||
lf = LFCommands(t)
|
||||
t.send_ng.return_value = make_response(Cmd.LF_T55XX_RESET_READ, 0, b"")
|
||||
asyncio.get_event_loop().run_until_complete(lf.t55.reset(downlink_mode=2))
|
||||
cmd, payload = t.send_ng.call_args.args[0], t.send_ng.call_args.args[1]
|
||||
assert cmd == Cmd.LF_T55XX_RESET_READ
|
||||
# single flags byte carrying downlink_mode<<3
|
||||
assert payload == struct.pack("<B", 2 << 3)
|
||||
|
||||
def test_lf_t55_config_client_side():
|
||||
t = AsyncMock()
|
||||
lf = LFCommands(t)
|
||||
|
||||
@@ -564,8 +564,12 @@ class TestCatalog:
|
||||
assert T5577.get("READ").build("0")[0] == 0x01
|
||||
assert T5577.get("WRITE").build("0", "0")[0] == 0x02
|
||||
assert T5577.get("WAKE").build("0")[0] == 0x03
|
||||
assert {c.name for c in T5577.commands.values()} == {"READ", "WRITE", "WAKE", "DETECT"}
|
||||
assert T5577.get("RESET").build()[0] == 0x00 # reset command = downlink opcode 00
|
||||
assert {c.name for c in T5577.commands.values()} == {"READ", "WRITE", "WAKE", "RESET", "DETECT"}
|
||||
assert T5577.get("READ").run is not None and LF_READONLY.get("INFO").run is not None
|
||||
# READ/WRITE take optional page + password; build() tolerates the extra args
|
||||
assert T5577.get("READ").build("4", "1", "0xAABBCCDD")[0] == 0x01
|
||||
assert T5577.get("WRITE").build("4", "12345678", "1", "0xAABBCCDD")[0] == 0x02
|
||||
|
||||
def test_opcode_covers_two_phase_write(self):
|
||||
# opcode() tolerates data args (which need valid hex) and multi-frame builds
|
||||
@@ -588,6 +592,52 @@ class TestCatalog:
|
||||
assert TYPE2.by_opcode(0x99) is None
|
||||
|
||||
|
||||
class TestT5577Run:
|
||||
"""The T5577 run() functions thread page/password through to the lf.t55 device methods."""
|
||||
|
||||
def test_write_threads_page_and_password(self):
|
||||
dev = MagicMock()
|
||||
dev.lf.t55.writebl.return_value = {"status": 0}
|
||||
out = T5577.get("WRITE").run(dev, "4", "0x12345678", "1", "0xAABBCCDD")
|
||||
dev.lf.t55.writebl.assert_called_once_with(4, 0x12345678, page=1, password=0xAABBCCDD)
|
||||
assert "block 4 (page 1)" in out and "ok" in out and "AABBCCDD" in out
|
||||
|
||||
def test_write_defaults_are_page0_no_password(self):
|
||||
dev = MagicMock()
|
||||
dev.lf.t55.writebl.return_value = {"status": 0}
|
||||
T5577.get("WRITE").run(dev, "4", "0x12345678")
|
||||
dev.lf.t55.writebl.assert_called_once_with(4, 0x12345678, page=0, password=None)
|
||||
|
||||
def test_write_data_and_password_are_hex(self):
|
||||
# data/password are hex words (like pm3 `lf t55 write -d/-p`), no 0x needed
|
||||
dev = MagicMock()
|
||||
dev.lf.t55.writebl.return_value = {"status": 0}
|
||||
T5577.get("WRITE").run(dev, "0", "00088040", "0", "50524F58")
|
||||
dev.lf.t55.writebl.assert_called_once_with(0, 0x00088040, page=0, password=0x50524F58)
|
||||
|
||||
def test_reset_sends_reset(self):
|
||||
dev = MagicMock()
|
||||
dev.lf.t55.reset.return_value = {"status": 0}
|
||||
assert "ok" in T5577.get("RESET").run(dev)
|
||||
dev.lf.t55.reset.assert_called_once_with()
|
||||
|
||||
def test_read_config_forwards_password(self):
|
||||
# block 0 -> read_config; a password reads in password mode (readbl(0, password=...))
|
||||
dev = MagicMock()
|
||||
dev.lf.t55.readbl.return_value = None # no envelope -> "no clean config"
|
||||
dev.lf.download_samples.return_value = b""
|
||||
out = T5577.get("READ").run(dev, "0", "0", "0x11223344")
|
||||
dev.lf.t55.readbl.assert_called_with(0, password=0x11223344)
|
||||
assert "11223344" in out
|
||||
|
||||
def test_read_block_forwards_page_and_password(self):
|
||||
dev = MagicMock()
|
||||
dev.lf.t55.readbl.return_value = None
|
||||
dev.lf.download_samples.return_value = b""
|
||||
T5577.get("READ").run(dev, "2", "1", "0x11223344")
|
||||
dev.lf.t55.readbl.assert_called_with(2, page=1, password=0x11223344)
|
||||
|
||||
|
||||
class TestFunctionCalls:
|
||||
def _id(self, sak=0x00):
|
||||
return RawSession(device=_mock_device(
|
||||
|
||||
Reference in New Issue
Block a user