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:
michael
2026-07-16 17:20:11 -07:00
parent 218e6dfc2a
commit 8f136e7dc4
5 changed files with 140 additions and 31 deletions

View File

@@ -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 ----

View File

@@ -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

View File

@@ -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):

View File

@@ -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)

View File

@@ -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(