Compare commits

..

7 Commits

Author SHA1 Message Date
michael
907522f0ae feat(lf): test-mode T55xx write (opcode 01) for recovery
Add a `test` flag to lf.t55.writebl that sets the firmware test-mode bit, sending
the datasheet's opcode-01 reconfiguration write (§5.10.3) instead of the standard
opcode-10 write. This is the path that can rewrite a tag a normal write can't
(corrupted/locked config), when the master key still permits it. Matches the C
client's `lf t55 write -t`. Default off; routine writes are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 20:01:24 -07:00
michael
a990d725ef feat(lf): NRZ + biphase raw physical-layer demod, with a fallback in identify_lf
Add the two missing ASK line codes so an unrecognised tag yields its raw bits
instead of reading as "nothing":

- ask.nrz_bits: NRZ/direct demod. Keys the bit clock off the shortest run (not
  2x a half-bit as in Manchester), resamples, yields both polarities/phases.
- ask.biphase_bits: biphase/differential-Manchester, reusing dsp.biphase_raw_decode
  (the FDX-B path).
- capture.demod_raw: runs both, finds the shortest clean repeating frame, returns
  {period, bits, hex} per encoding + the recovered clock. Sits BELOW the credential
  decoders — it names no format, just hands back the bits.
- identify_lf falls back to demod_raw when no credential decodes off the air, so a
  strong-but-unknown LF signal surfaces its frame instead of None.

Hardware-hardened: on a live non-NRZ/biphase tag the first cut reported a spurious
16-bit "frame" — biphase-decoding a non-biphase signal emits a short prefix then a
constant run that trivially repeats at any period. _frame_period now rejects
near-constant streams and finds the smallest *true* period (so 0101 fills read as
period 2 and are rejected, not reported at min_p). Regression tests cover all three.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 19:13:36 -07:00
michael
e3a394859c docs: correct the T5577 block-order finding — MAXBLK=1 test is the decisive one
The block-3 write-read-back wasn't decisive: writebl and readbl share the address
path, so they round-trip regardless of addressing correctness. Since proxmark,
Flipper, and pm3py all write the EM4100 header into block 1, ruling out a pm3py
1<->2 read-swap required an addressing-free read. Set MAXBLK=1 so regular-read
streams only physical block 1, read it off the air, and it matched READ(1)
(non-header BDEF7BC0). Confirmed: the tag is a genuine reverse-order clone, pm3py
reads it correctly, no addressing bug. Restored MAXBLK=2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 18:27:07 -07:00
michael
4226c9a11c docs: C client oracle + LF demod parity notes
Capture the investigation behind the T5577 live validation: why the stock C
client won't run here (VS Code snap sandbox injects core20 libpthread — not a
client bug), the LF clock-detection parity gap vs the C client's DetectASKClock
(pm3py's detect_clock was weaker; now partially closed), and the data-block read
investigation. The latter reached ground truth via datasheet + C source + an
on-hardware write-read-back: no read/addressing bug — the live tag is just a
reverse-order EM4100 clone; the only artifact is unresolved Manchester polarity
(a real, resolvable improvement, noted).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 17:59:14 -07:00
michael
8833753c45 fix(lf): detect_clock keys off the shortest well-supported interval, not the min
detect_clock estimated the bit clock from the *minimum* edge interval, so a few
stray sub-bit edges (which every real ADC capture carries) collapsed the estimate.
On a strongly-coupled, rail-clipping EM4100 the true half-bit of 32 dropped to ~3,
snapping the data clock to 8 and turning the ASK demod into all phase-error markers
— no config, no credential decoded.

Base the symbol on the shortest interval with real support (>=20% of the modal
count) instead of the raw minimum. Verified: clean synthetic still reads 32; the
hardware capture that broke it now clocks correctly. Live on a T5577 emulating
EM4100 00FFFFFFFF: DETECT decodes config 00148040, identify returns the credential.

Regression test reproduces the exact failure (sparse stray edges): old estimator
3.67 (dead demod), new 31.99.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 17:59:14 -07:00
michael
8f136e7dc4 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>
2026-07-16 17:20:11 -07:00
michael
218e6dfc2a docs(rawcli): record ICODE DNA catalog sourcing plan + datasheet pointers
Capture (before the follow-up work starts) how to build the DNA catalog from the local
datasheets: SL2S6002_SDS for the command set + memory map (63 user blocks, block 63 =
counter, 48 config blocks), SL2S2602 (SLIX2) for shared opcodes, hardware-confirmed
READ_CONFIG 0xC0/WRITE_CONFIG 0xC1, and docs/NTAG5_SECURITY.md for the AES commands.
Marks NTAG5 done; notes the real NTAG5 datasheets (NTP5210/NTA5332) are also local.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:43:19 -07:00
11 changed files with 492 additions and 45 deletions

View File

@@ -0,0 +1,93 @@
# C client oracle + LF demod parity — future-work notes
*2026-07-16. Written while finishing the rawcli T5577 catalog and validating on real
hardware (a T5577 emulating **EM4100 `00FFFFFFFF`**, config word **`00148040`**).*
I wanted the stock proxmark3 C client as a ground-truth **oracle** for LF demod — to
cross-check pm3py's decode of the live tag. It wouldn't run in this environment, and digging
into why surfaced the notes below. **Net: there is no C-client _code_ bug to PR from what we
found** — the two real items are (1) an environment blocker and (2) a pm3py-side demod-parity
gap. Captured here so a future effort doesn't re-derive it.
## 1. The C client won't run inside the VS Code snap sandbox (oracle blocker)
- **Symptom:** `./pm3 -p /dev/ttyACM0 -c 'lf search'`
`symbol lookup error: /snap/core20/current/lib/x86_64-linux-gnu/libpthread.so.0:
undefined symbol: __libc_pthread_init, version GLIBC_PRIVATE`
- **Root cause:** this shell runs *inside* the VS Code snap (`SNAP_REVISION`, `SNAP_REAL_HOME`,
`/snap/code/250/...`). The snap runtime injects core20's older glibc / `libpthread.so.0`
ahead of the system libs the client was built against, so a `GLIBC_PRIVATE` symbol the
system libc no longer exports gets looked up in the old snap libpthread and fails.
- **Confirmed it is NOT a client defect:**
- The binary has **no RPATH/RUNPATH**; its `NEEDED` libs (`libpython3.12`, `Qt5*`,
`readline`, …) all resolve to system `/lib/x86_64-linux-gnu` under `ldd`.
- `LD_LIBRARY_PATH` is **unset**; clearing `LD_LIBRARY_PATH`/`GTK_PATH` did not help — the
injection is at the snap-runtime level, not via a variable we can scrub.
- **To use the client as an oracle:** run it from a **non-snap login shell / real terminal**
(outside VS Code's snap), or build+run it inside a matched (non-snap) environment. No
proxmark3 change fixes a sandbox library injection.
- **Marginal upstream idea (only if it ever matters):** the `pm3` launcher could sanitize
obviously-contaminating library paths before `exec`, but that is a weak, environment-specific
band-aid — not worth a PR on its own.
- Client checkout here: `dangerous-tac0s/proxmark3` `v4.20728-1264-g273777b21`.
## 2. LF clock detection — pm3py's port was weaker than the C client (pm3py-side parity)
We found and fixed a real bug in pm3py `pm3py/lf/dsp.py::detect_clock`: it estimated the bit
clock from the **minimum** edge interval, so a handful of stray sub-bit edges on a
strongly-coupled, rail-clipped tag collapsed the estimate (true half-bit 32 → ~3), snapping the
data clock to 8 and producing an all-phase-error ASK demod (nothing decoded). Fixed to key off
the **shortest _well-supported_ interval** (≥20% of the modal count) instead of the raw minimum.
The C client's `DetectASKClock` (`common/lfdemod.c`) is already robust to this — worth porting
its approach for full parity:
- It first runs `DetectCleanAskWave` and, for clean/strong/**clipped** peaks, routes to a
dedicated `DetectStrongAskClock` — exactly the case that broke our port.
- Otherwise it **error-scores every candidate clock** `{8,16,32,40,50,64,100,128,272}` against
the wave and picks the best fit — not a single-interval heuristic.
**Action (pm3py, not a C-client PR):** port the candidate-clock error-scoring + a
strong/clean-ASK fast path into `pm3py/lf`. Our support-based `detect_clock` fix is a partial,
targeted step; the C client's scoring is the fuller solution.
## 3. Data-block reads — investigated to ground truth (no bug), one real improvement
Post-fix, pm3py decodes the live tag as:
- Emitted credential: **EM4100 `00FFFFFFFF`**
- T5577 config block 0: **`00148040`** (Manchester, RF/64, MAXBLK 2, `ST=0`, non-inverted →
EM4100/EM4102) — decoded rotation-resolved against presets (reliable).
- Data blocks: `READ(1)=A108421F`, `READ(2)=007FE108`, which at first looked *wrong* (not a
rotation of the encoder's `FF801EF7`/`BDEF7BC0`).
Chased to ground truth against the **ATA5577C datasheet + proxmark3 C source + an on-hardware
write-read-back** — and there is **no read/addressing bug**:
- Datasheet §5.9: the direct-access command (`opcode 10` + `0` + 3-bit addr — exactly what the
firmware sends) reads *only the addressed block*, repetitively. A clean 32-bit repeat in the
capture proves block-read mode engaged (the 64-bit emulation stream can't produce one).
- `T55xx_SetBits` clocks the address MSB-first; pm3py's `readbl` payload matches the firmware
struct byte-for-byte. Addressing is stock-proxmark, verified end-to-end.
- A block-3 write-read-back was **not** decisive on its own: `writebl` and `readbl` share the
same address path, so they round-trip regardless of whether addressing is correct (it only
proved the demod inverts). Proxmark, Flipper, and pm3py all write the header into block 1, so
the alternative "pm3py reads 1↔2 swapped" had to be excluded with an **addressing-free** read.
- **Decisive test (MAXBLK=1):** temporarily set the config MAXBLK field to 1 (`00148040 →
00148020`) so regular-read streams *only physical block 1* (datasheet §5.11.1), read it off
the air (no read-command addressing), then restored `00148040`. Physical block 1 came back as
`~BDEF7BC0` — the **non-header** half — matching `READ(1)`. So `READ(1)` == physical block 1.
Conclusion: this tag was simply **cloned with the two EM4100 halves in reverse order**
(block 1 = `BDEF7BC0`, block 2 = `FF801EF7`) by a non-proxmark/Flipper/pm3py tool; both orders
stream the same cyclic waveform and read as `00FFFFFFFF`. pm3py reads them correctly. The "wrong"
appearance was polarity (inversion) + rotation, i.e. the documented "best-effort — bit alignment
not verified".
**Real improvement (open):** `read_t55xx_block` should *resolve* data-block polarity/rotation
instead of leaving it best-effort. Polarity is determinable — read block 0 (config), resolve its
polarity against the known preset, and carry that polarity to the data blocks (they share the
tag's modulation). Rotation can be anchored off the block-read response start. The C client's
`cmdlft55xx` demod already does this alignment — worth porting alongside §2. Still not
cross-checked against `lf t55 dump` (client blocked by §1), but the write-read-back makes that
non-blocking.

View File

@@ -41,7 +41,7 @@ rendered dropdown on a real terminal is the only thing left unautomated.
authenticate step is the reader's job before these. authenticate step is the reader's job before these.
- Autocomplete must work in hex + binary (see the bug above). - Autocomplete must work in hex + binary (see the bug above).
## After MFC — 15693 vicinity chips: ICODE SLIX ✅, ICODE SLIX2 ✅, NTAG5 (next) ## After MFC — 15693 vicinity chips: ICODE SLIX ✅, SLIX2 ✅, NTAG5 ✅, ICODE DNA (next)
1. **15693 header builder** ✅ — `iso15_frame()` (flags + command + NXP mfg 0x04), shared by all 1. **15693 header builder** ✅ — `iso15_frame()` (flags + command + NXP mfg 0x04), shared by all
15693 catalogs. The **full request-flags byte** is settable: sub-carrier (0x01), data-rate, 15693 catalogs. The **full request-flags byte** is settable: sub-carrier (0x01), data-rate,
@@ -68,6 +68,26 @@ count-1`, with the inventory flags set by `iso15_frame(inventory=…, afi=…)`.
mask-based selective anticollision, and AFI filter all supported. **15693 is now datasheet-complete mask-based selective anticollision, and AFI filter all supported. **15693 is now datasheet-complete
end to end** (flags byte + both SLIX/SLIX2 command sets + inventory-read body). end to end** (flags byte + both SLIX/SLIX2 command sets + inventory-read body).
### NEXT: ICODE DNA catalog (`_DNA`)
DNA is now identified ("ICODE DNA") but borrows `_SLIX2`. Build a dedicated `_DNA` catalog. Sourcing
(datasheets are local in the repo root — see [[reference_datasheets]] in memory):
- **Command set + semantics + memory map:** `SL2S6002_SDS.pdf` (the DNA short data sheet). It names
the commands but — being the *short* sheet — omits exact opcodes (defers to the NDA full sheet).
- **Opcodes for the shared commands:** `SL2S2602.pdf` (SLIX2) — DNA extends SLIX2, so EAS/AFI,
password (0xB2-B5), READ_SIGNATURE (0xBD), persistent-quiet, etc. carry over. Start from
`_SLIX2.commands` like `_NTAG5` did.
- **DNA-specific:** READ_CONFIG (0xC0) / WRITE_CONFIG (0xC1) — hardware-confirmed on the real DNA;
CID (default 0xC000); AES CHALLENGE (0x39) / AUTHENTICATE (0x35) / READBUFFER (0x3A) from
`docs/NTAG5_SECURITY.md` (verified protocol) — same crypto sub-protocol as the NTAG5 AES follow-up.
- **Memory map** (for `memory.py`): user = **63 blocks (0-62)**, **block 63 = 16-bit counter**;
**config memory = 48 blocks** via READ_CONFIG (holds keys, CID, originality signature, privileges).
- Route `"DNA"``_DNA` in `catalog_for` (currently → `_SLIX2`). Hardware-verify against the real
DNA (finicky: 16-slot inventory + addressed + retries, per [[project_ntag5_dna_id]]).
The NTAG5 AES commands (0x35/0x39/0x3A) follow-up shares this crypto work — source from `NTA5332.pdf`
(the real NTAG5 Link/Boost datasheet, also local) + `docs/NTAG5_SECURITY.md`.
**Hardware verify pending:** SLIX2 catalog was verified only against unit tests + a live-session **Hardware verify pending:** SLIX2 catalog was verified only against unit tests + a live-session
drive (no SLIX2 tag on the reader). Confirm on a real SLIX2 when one is available. drive (no SLIX2 tag on the reader). Confirm on a real SLIX2 when one is available.

View File

@@ -460,40 +460,64 @@ _NTAG5 = _catalog(
# ---- LF T5577 / ATA5577 (block read/write over the T55xx downlink; executes via lf.t55) ---- # ---- 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 # 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. # through run() (the device method), since LF isn't a raw-byte exchange like 14a.
def _t55_read(dev, block): def _word(s) -> int:
b = _int(block) """A 32-bit T55xx data/password word — parsed as hex (0x/spaces tolerated), matching the pm3
if b == 0: # config block — the reliable one 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 from pm3py.lf.capture import read_config
r = read_config(dev) r = read_config(dev, password=pwd)
if r: if r:
return (f"block 0 = {r['config_hex']} ({r['modulation']}, RF/{r['data_bit_rate']}, " return (f"block 0 = {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)}")
return "READ block 0: no clean config recovered" return f"READ block 0: no clean config recovered{_pwd_note(password)}"
from pm3py.lf.capture import read_t55xx_block 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: if word is None:
return f"READ block {b}: no clean response (LF block reads are demod-dependent)" return f"READ {where}: no clean response (LF block reads are demod-dependent){_pwd_note(password)}"
return f"block {b} = {word:08X} (best-effort — bit alignment not verified)" return f"{where} = {word:08X} (best-effort — bit alignment not verified){_pwd_note(password)}"
def _t55_write(dev, block, data): def _t55_write(dev, block, data, page="0", password=""):
r = dev.lf.t55.writebl(_int(block), _int(data)) 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 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): 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'}" 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 from pm3py.lf.capture import read_config
r = read_config(dev) r = read_config(dev, password=_optpwd(password))
if not r: 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']}, " 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): def _lf_reread(dev):
@@ -502,16 +526,26 @@ def _lf_reread(dev):
return r["label"] if r else "no LF credential decoded" 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 = _catalog(
"T5577 / ATA5577", "lf", "T5577 / ATA5577", "lf",
_c("READ", ["block"], build=lambda block: bytes([0x01, _int(block)]), run=_t55_read, _c("READ", ["block", "page", "password"],
help="read a 32-bit block (0=config, 7=password, 1-6=data) — best-effort demod"), build=lambda block, page="0", password="": bytes([0x01, _int(block)]), run=_t55_read,
_c("WRITE", ["block", "data"], build=lambda block, data: bytes([0x02, _int(block)]), help="read a 32-bit block (0=config, 7=password, 1-6=data); <page> 1 = traceability, "
run=_t55_write, help="write 32-bit <data> to <block> (0x02)"), "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, _c("WAKE", ["password"], build=lambda password: bytes([0x03]), run=_t55_wake,
help="wake a password-protected tag with the block-7 password (0x03)"), help="wake a password-protected tag with the block-7 password — AOR (0x03)"),
_c("DETECT", [], build=lambda: bytes([0x01, 0x00]), run=_t55_detect, _c("RESET", [], build=lambda: bytes([0x00]), run=_t55_reset,
help="read + decode the config block (block 0)"), 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 ---- # ---- LF read-only credentials (EM4100/HID/AWID/FDX-B): broadcast tags with no command set ----

View File

@@ -25,8 +25,15 @@ class T55xxCommands:
} }
async def writebl(self, block: int, data: int, page: int = 0, async def writebl(self, block: int, data: int, page: int = 0,
password: int | None = None, downlink_mode: int = 0) -> dict: password: int | None = None, downlink_mode: int = 0,
"""Write a T55xx block.""" test: bool = False) -> dict:
"""Write a T55xx block.
``test`` uses the test-mode write (downlink opcode 01) instead of the standard opcode 10.
Test mode is the datasheet's reconfiguration path (§5.10.3) — it can rewrite a tag that a
normal write can't (a corrupted/locked config), provided the master key still permits it
(allowed for key 9, denied once key 6 is set). Use it for recovery, not routine writes.
"""
pwd = password if password is not None else 0 pwd = password if password is not None else 0
# Firmware t55xx_write_block_t: data(4)+pwd(4)+blockno(1)+flags(1). # Firmware t55xx_write_block_t: data(4)+pwd(4)+blockno(1)+flags(1).
# flags: 0x01 PwdMode, 0x02 Page, 0x04 test, 0x18 downlink_mode<<3. # flags: 0x01 PwdMode, 0x02 Page, 0x04 test, 0x18 downlink_mode<<3.
@@ -35,6 +42,8 @@ class T55xxCommands:
flags |= 0x01 flags |= 0x01
if page: if page:
flags |= 0x02 flags |= 0x02
if test:
flags |= 0x04
flags |= (downlink_mode & 0x03) << 3 flags |= (downlink_mode & 0x03) << 3
payload = struct.pack("<IIBB", data, pwd, block, flags) payload = struct.pack("<IIBB", data, pwd, block, flags)
resp = await self._t.send_ng(Cmd.LF_T55XX_WRITEBL, payload, timeout=5.0) resp = await self._t.send_ng(Cmd.LF_T55XX_WRITEBL, payload, timeout=5.0)
@@ -48,6 +57,18 @@ class T55xxCommands:
resp = await self._t.send_ng(Cmd.LF_T55XX_WAKEUP, payload) resp = await self._t.send_ng(Cmd.LF_T55XX_WAKEUP, payload)
return {"status": resp.status} 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 # Default T55xx downlink-mode timing table (generic/PM3 Easy build), stored
# as raw struct values (field-clocks x 8). Mirrors T55xx_Timing in # 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 # armsrc/lfops.c. Firmware exposes no GET, and CMD_LF_T55XX_SET_CONFIG is

View File

@@ -17,6 +17,6 @@ Layers:
Everything is testable without hardware: the LF transponder models already *encode* these Everything is testable without hardware: the LF transponder models already *encode* these
protocols, so a synthetic envelope built from an encoder round-trips through the demod. protocols, so a synthetic envelope built from an encoder round-trips through the demod.
""" """
from .capture import demod_samples, read_config, identify_lf from .capture import demod_samples, demod_raw, read_config, identify_lf
__all__ = ["demod_samples", "read_config", "identify_lf"] __all__ = ["demod_samples", "demod_raw", "read_config", "identify_lf"]

View File

@@ -127,3 +127,41 @@ def manchester_bits(samples):
for phase in (0, 1): for phase in (0, 1):
for minv in (False, True): for minv in (False, True):
yield dsp.manchester_decode(raw, phase, minv) yield dsp.manchester_decode(raw, phase, minv)
def nrz_bits(samples):
"""Yield candidate raw NRZ / direct bit streams. In NRZ (the T5577 "direct" line code) the
data bit is output with no coding — the envelope sampled once per bit *is* the data — so the
bit clock is the shortest run itself, not twice a half-bit as in Manchester. Yields both
polarities over a few phase offsets; the caller keeps whichever repeats cleanly."""
core = trim_to_signal(samples)
if len(core) < 64 or dsp.is_flat(core):
return
binary = dsp.binarize(core)
half = dsp.detect_clock(binary)
if not half or half < 8:
return
clk = min(_CLOCKS, key=lambda c: abs(c - half)) # NRZ: one bit = the shortest run
for offset in (0.5, 0.0, 0.25, 0.75):
raw = dsp.resample(binary, clk, offset)
if len(raw) < 32:
continue
yield raw
yield [b ^ 1 for b in raw]
def biphase_bits(samples):
"""Yield candidate raw bit streams for a biphase / differential-Manchester ASK envelope — the
FDX-B line code (also Indala, Gallagher, …). Resample to half-bit resolution and run the
biphase decoder for both polarities; phase-error markers are dropped."""
core = trim_to_signal(samples)
if len(core) < 64 or dsp.is_flat(core):
return
binary = dsp.binarize(core)
half = dsp.detect_clock(binary)
if not half or half < 4:
return
raw = dsp.resample(binary, half)
for invert in (0, 1):
bits, _err = dsp.biphase_raw_decode(raw, 0, invert)
yield [b for b in bits if b != 7]

View File

@@ -10,6 +10,7 @@ from __future__ import annotations
import inspect import inspect
from . import ask from . import ask
from . import dsp
from . import protocols from . import protocols
# how many envelope samples to pull from BigBuf after a capture (a T55xx/EM frame is a few # how many envelope samples to pull from BigBuf after a capture (a T55xx/EM frame is a few
@@ -53,6 +54,70 @@ def demod_samples(samples) -> dict | None:
return None return None
def _transition_density(bits) -> float:
return sum(1 for i in range(1, len(bits)) if bits[i] != bits[i - 1]) / max(1, len(bits) - 1)
def _frame_period(bits, min_p: int = 16) -> int | None:
"""The smallest bit-period the stream repeats at (its frame length), or None.
Guards against the failure mode where the wrong line code decodes a signal into a mostly
*constant* run (e.g. biphase-decoding a non-biphase tag emits a short prefix then all-1s):
such a stream trivially "repeats" at any period. So we reject a near-constant stream up front,
require room for a few real repeats, and reject a frame that is itself near-constant or
trivially sub-periodic (an alternating 0101 fill)."""
n = len(bits)
if n < 3 * min_p:
return None
if _transition_density(bits) < 0.10: # mostly constant -> not a real frame
return None
# find the *smallest true* period, from 2 up — so a trivial fill (0101 -> period 2, 0011 ->
# period 4) is caught as tiny and rejected, instead of being reported at the first p >= min_p.
for p in range(2, n // 3 + 1):
span = n - p
if sum(1 for i in range(span) if bits[i] == bits[i + p]) >= span * 0.97:
return p if p >= min_p else None # real tag frame is >= min_p bits
return None
def demod_raw(samples) -> dict | None:
"""Physical-layer fallback for an ASK tag no named decoder recognises: recover the raw
repeating frame under the NRZ and biphase line codes so the tag still yields its bits.
Returns whichever encodings produced a clean repeating frame (bit string + hex + period in
bits) plus the recovered bit clock, or None if nothing repeats cleanly. This is deliberately
*below* the credential decoders — it names no format, it just hands back the bits an expert
(or a future format decoder) can read."""
core = ask.trim_to_signal(samples)
if len(core) < 64 or dsp.is_flat(core):
return None
half = dsp.detect_clock(dsp.binarize(core))
if not half:
return None
encs: dict = {}
for name, gen in (("nrz", ask.nrz_bits), ("biphase", ask.biphase_bits)):
best = None
for bits in gen(samples):
p = _frame_period(bits)
if p and (best is None or p < best[0]):
best = (p, bits[:p])
if best:
p, frame = best
s = "".join(str(b) for b in frame)
encs[name] = {"period": p, "bits": s, "hex": f"{int(s, 2):0{-(-p // 4)}X}"}
if not encs:
return None
clk = min(ask._CLOCKS, key=lambda c: abs(c - half))
return {"protocol": "raw", "shortest_run": round(half, 1), "rf": f"RF/{clk}",
"encodings": encs}
def raw_label(raw: dict) -> str:
"""One-line name for a raw physical-layer dump (no credential decoded)."""
parts = [f"{k} {v['hex']} ({v['period']}b)" for k, v in raw["encodings"].items()]
return f"unknown LF ({raw['rf']}) — " + "; ".join(parts)
# download a fixed, safe span rather than trusting lf.read()'s reported count — on a flaky NG # download a fixed, safe span rather than trusting lf.read()'s reported count — on a flaky NG
# link that count is sometimes garbage, and over-reading spills into BigBuf memory past the # link that count is sometimes garbage, and over-reading spills into BigBuf memory past the
# samples. 12000 is under a normal acquisition, enough frames for any LF frame to repeat. # samples. 12000 is under a normal acquisition, enough frames for any LF frame to repeat.
@@ -108,19 +173,22 @@ def read_emitted(device) -> dict | None:
return demod_samples(data) if data else 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, """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.""" 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)) ``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 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 """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 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 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.""" via read_config, which resolves the rotation against known presets. ``page`` 1 reaches the
data = _capture(device, lambda: _try(lambda: device.lf.t55.readbl(int(block)), None)) 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: if not data:
return None return None
for bits in ask.manchester_bits(data): for bits in ask.manchester_bits(data):
@@ -156,7 +224,14 @@ def identify_lf(device, emit=None) -> dict | None:
config = read_config(device) # T55xx? -> chip + emulation config = read_config(device) # T55xx? -> chip + emulation
emitted = read_emitted(device) # what's on the air -> EM4100 ID (etc.) emitted = read_emitted(device) # what's on the air -> EM4100 ID (etc.)
if config is None and emitted is None: # nothing named decoded off the air -> try the physical-layer fallback (NRZ/biphase) so a tag
# no credential decoder handles still yields its raw bits rather than reading as "nothing".
raw = None
if emitted is None:
data = _capture(device, lambda: _try(lambda: device.lf.read(samples=_SAMPLE_COUNT), None))
raw = demod_raw(data) if data else None
if config is None and emitted is None and raw is None:
return None return None
chip = None chip = None
@@ -169,15 +244,21 @@ def identify_lf(device, emit=None) -> dict | None:
f" -> {emulating}") f" -> {emulating}")
if emitted is not None: if emitted is not None:
emit(f" emitted: {credential_label(emitted)}") emit(f" emitted: {credential_label(emitted)}")
elif raw is not None:
emit(f" emitted (raw, no format decoded): {raw_label(raw)}")
# label: name the chip and what it's actually emitting (the reliable, decoded credential); # label: name the chip and what it's actually emitting (the reliable, decoded credential);
# fall back to the config's emulation guess, or just the emitted credential. # fall back to the raw physical dump, then the config's emulation guess.
if chip and emitted: if chip and emitted:
label = f"{chip} ({credential_label(emitted)})" label = f"{chip} ({credential_label(emitted)})"
elif chip and raw:
label = f"{chip} ({raw_label(raw)})"
elif chip: elif chip:
label = f"{chip} ({emulating})" label = f"{chip} ({emulating})"
else: elif emitted:
label = credential_label(emitted) label = credential_label(emitted)
else:
label = raw_label(raw)
return { return {
"found": True, "found": True,
@@ -187,5 +268,6 @@ def identify_lf(device, emit=None) -> dict | None:
"config": config["config_hex"] if config else None, "config": config["config_hex"] if config else None,
"emulating": emulating, "emulating": emulating,
"emitted": emitted, "emitted": emitted,
"raw": raw,
"label": label, "label": label,
} }

View File

@@ -55,23 +55,32 @@ def _edges(binary: list[int]) -> list[int]:
return [i for i in range(1, len(binary)) if binary[i] != binary[i - 1]] return [i for i in range(1, len(binary)) if binary[i] != binary[i - 1]]
def detect_clock(binary: list[int], min_run: int = 3) -> float | None: def detect_clock(binary: list[int], min_run: int = 3, support: float = 0.2) -> float | None:
"""Estimate the shortest symbol width (samples) from the edge-interval distribution. """Estimate the shortest symbol width (samples) from the edge-interval distribution.
For Manchester this is the *half-bit* (there is always a mid-bit transition, so runs are For Manchester this is the *half-bit* (there is always a mid-bit transition, so runs are
one or two half-bits); for plain OOK it is the bit itself. Runs shorter than ``min_run`` one or two half-bits); for plain OOK it is the bit itself. Runs shorter than ``min_run``
are treated as noise/jitter and ignored. Returns the mean of the shortest interval cluster, are treated as noise/jitter and ignored. Returns the mean of the shortest interval cluster,
or ``None`` when there aren't enough clean edges to decide.""" or ``None`` when there aren't enough clean edges to decide.
The base symbol is the shortest interval with *real support* — a count at least ``support``×
the most-common interval's count — not the raw minimum. A real ADC capture always carries a
few stray sub-bit edges; keying off the bare minimum let a single 3-sample outlier collapse a
clean RF/64 tag's estimate from 32 to ~3 (hardware-observed on a strongly-coupled EM4100),
snapping the data clock to 8 and killing the demod. The dominant interval is robust to that."""
edges = _edges(binary) edges = _edges(binary)
if len(edges) < 4: if len(edges) < 4:
return None return None
intervals = [edges[i] - edges[i - 1] for i in range(1, len(edges)) if edges[i] - edges[i - 1] >= min_run] intervals = [edges[i] - edges[i - 1] for i in range(1, len(edges)) if edges[i] - edges[i - 1] >= min_run]
if len(intervals) < 3: if len(intervals) < 3:
return None return None
intervals.sort() counts: dict[int, int] = {}
base = intervals[0] for d in intervals:
# average the cluster within 1.5x of the shortest interval -> the fundamental symbol width counts[d] = counts.get(d, 0) + 1
cluster = [d for d in intervals if d <= base * 1.5] floor = max(counts.values()) * support
base = min(d for d, n in counts.items() if n >= floor) # shortest well-supported interval
# average the cluster around the base (0.75x1.5x) -> the fundamental symbol width
cluster = [d for d in intervals if base * 0.75 <= d <= base * 1.5]
return sum(cluster) / len(cluster) return sum(cluster) / len(cluster)

View File

@@ -57,6 +57,17 @@ def test_lf_t55_writebl():
# PwdMode(0x01) | Page(0x02) | downlink 2<<3 (0x10) = 0x13 # PwdMode(0x01) | Page(0x02) | downlink 2<<3 (0x10) = 0x13
assert flags == 0x13 assert flags == 0x13
def test_lf_t55_writebl_testmode():
t = AsyncMock()
lf = LFCommands(t)
t.send_ng.return_value = make_response(Cmd.LF_T55XX_WRITEBL, 0, b"")
asyncio.get_event_loop().run_until_complete(
lf.t55.writebl(block=0, data=0x00148040, test=True))
_, _, blockno, flags = struct.unpack("<IIBB", t.send_ng.call_args.args[1])
assert blockno == 0
assert flags & 0x04 # test-mode bit set (downlink opcode 01)
assert not (flags & 0x01) # no password mode by default
def test_lf_t55_wakeup(): def test_lf_t55_wakeup():
t = AsyncMock() t = AsyncMock()
lf = LFCommands(t) lf = LFCommands(t)
@@ -69,6 +80,16 @@ def test_lf_t55_wakeup():
assert pwd == 0x11223344 assert pwd == 0x11223344
assert flags == (3 << 3) 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(): def test_lf_t55_config_client_side():
t = AsyncMock() t = AsyncMock()
lf = LFCommands(t) lf = LFCommands(t)

View File

@@ -8,6 +8,7 @@ from unittest.mock import MagicMock
from pm3py.lf import dsp, fsk, demod_samples, read_config, identify_lf from pm3py.lf import dsp, fsk, demod_samples, read_config, identify_lf
from pm3py.lf import protocols from pm3py.lf import protocols
from pm3py.lf import capture
from pm3py.transponders.lf.em.em4100 import EM4100Tag from pm3py.transponders.lf.em.em4100 import EM4100Tag
from pm3py.transponders.lf.hid.hid import HIDProxTag from pm3py.transponders.lf.hid.hid import HIDProxTag
from pm3py.transponders.lf.atmel.t5577 import T5577Config, CONFIG_EM4100 from pm3py.transponders.lf.atmel.t5577 import T5577Config, CONFIG_EM4100
@@ -88,6 +89,19 @@ class TestDSP:
half = dsp.detect_clock(dsp.binarize(env)) half = dsp.detect_clock(dsp.binarize(env))
assert half is not None and abs(half - 32) < 4 # RF/64 -> 32-sample half-bit assert half is not None and abs(half - 32) < 4 # RF/64 -> 32-sample half-bit
def test_clock_survives_sparse_stray_edges(self):
# hardware regression: a strongly-coupled EM4100 gives a clean RF/64 signal (intervals
# dominated by 32/64) with a few sub-bit stray edges from ADC ripple. The old min-based
# estimator keyed off the bare shortest interval and collapsed to ~3 (-> data clock 8 ->
# dead demod). The dominant interval is still 32, so detect_clock must ride through it.
env = bytearray(_em4100_env(0x0102030405, rf=64, high=255, low=0))
for i in range(200, len(env), 900): # sparse single-sample spikes
env[i] = 0 if env[i] > 128 else 255
half = dsp.detect_clock(dsp.binarize(bytes(env)))
assert half is not None and abs(half - 32) < 4 # not dragged down to the ~3 outlier
r = protocols.decode_em4100(bytes(env))
assert r and r["id_hex"] == "0102030405" # end-to-end decode survives
def test_manchester_roundtrip(self): def test_manchester_roundtrip(self):
# (1,0)->1, (0,1)->0 at phase 0 # (1,0)->1, (0,1)->0 at phase 0
assert dsp.manchester_decode([1, 0, 0, 1, 1, 0]) == [1, 0, 1] assert dsp.manchester_decode([1, 0, 0, 1, 1, 0]) == [1, 0, 1]
@@ -259,6 +273,71 @@ def _fdxb_env(country, national, animal=0, **kw):
return _biphase_env(_fdxb_bits(country, national, animal), **kw) return _biphase_env(_fdxb_bits(country, national, animal), **kw)
# --- NRZ / raw physical-layer fallback --------------------------------------------------------
def _nrz_env(databits, rf=32, high=200, low=60, repeats=8):
"""Direct/NRZ ASK envelope: each data bit is output as-is, held for one bit-width (rf
samples). The inverse of ask.nrz_bits."""
env = []
for d in databits * repeats:
env += [high if d else low] * rf
return bytes(env)
def _cyclic_match(bitstr, frame) -> bool:
"""Is ``bitstr`` a rotation of ``frame`` or its bitwise inverse? Raw demod carries polarity
and start-of-frame ambiguity, so cyclic-plus-invert is the right equivalence."""
f = "".join(str(b) for b in frame)
if len(bitstr) != len(f):
return False
inv = "".join("1" if c == "0" else "0" for c in f)
return bitstr in (f + f) or bitstr in (inv + inv)
class TestRawDemod:
# a 32-bit frame with no short internal period (so the recovered frame length is 32)
FRAME = [1, 0, 1, 1, 0, 0, 1, 0, 1, 1, 1, 0, 0, 0, 1, 0,
0, 1, 1, 0, 1, 0, 0, 1, 1, 1, 0, 1, 0, 0, 0, 1]
def test_nrz_round_trips(self):
r = capture.demod_raw(_nrz_env(self.FRAME, rf=32))
assert r and "nrz" in r["encodings"]
enc = r["encodings"]["nrz"]
assert enc["period"] == 32
assert _cyclic_match(enc["bits"], self.FRAME)
assert r["rf"] == "RF/32"
def test_nrz_various_rates(self):
for rf in (32, 64):
r = capture.demod_raw(_nrz_env(self.FRAME, rf=rf))
assert r and _cyclic_match(r["encodings"]["nrz"]["bits"], self.FRAME)
def test_biphase_round_trips(self):
r = capture.demod_raw(_biphase_env(self.FRAME, rf=32))
assert r and "biphase" in r["encodings"]
assert _cyclic_match(r["encodings"]["biphase"]["bits"], self.FRAME)
def test_noise_returns_none(self):
assert capture.demod_raw(bytes([128] * 4000)) is None
assert capture.demod_raw(b"") is None
def test_raw_is_below_credentials(self):
# a genuine EM4100 must decode as a credential; the raw fallback is only for the unnamed
assert demod_samples(_em4100_env(0x0102030405))["protocol"] == "EM4100"
def test_generators_yield_bits(self):
from pm3py.lf import ask
assert any(len(b) >= 32 for b in ask.nrz_bits(_nrz_env(self.FRAME, rf=32)))
assert any(len(b) >= 32 for b in ask.biphase_bits(_biphase_env(self.FRAME, rf=32)))
def test_rejects_constant_tail(self):
# hardware regression: biphase-decoding a non-biphase tag emits a short prefix then a long
# constant run, which trivially "repeats" at any period. That must NOT report a frame.
assert capture._frame_period([0, 0, 0, 1, 0, 1, 0, 1] + [1] * 200) is None
assert capture._frame_period([1] * 200) is None # fully constant
assert capture._frame_period(([0, 1] * 100)) is None # alternating fill, sub-periodic
class TestCRC16: class TestCRC16:
def test_known_vectors(self): def test_known_vectors(self):
# "123456789" -> CRC-16/XMODEM 0x31C3, CRC-16/KERMIT 0x2189 (anchors the CRC impl) # "123456789" -> CRC-16/XMODEM 0x31C3, CRC-16/KERMIT 0x2189 (anchors the CRC impl)

View File

@@ -564,8 +564,12 @@ class TestCatalog:
assert T5577.get("READ").build("0")[0] == 0x01 assert T5577.get("READ").build("0")[0] == 0x01
assert T5577.get("WRITE").build("0", "0")[0] == 0x02 assert T5577.get("WRITE").build("0", "0")[0] == 0x02
assert T5577.get("WAKE").build("0")[0] == 0x03 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 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): def test_opcode_covers_two_phase_write(self):
# opcode() tolerates data args (which need valid hex) and multi-frame builds # 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 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: class TestFunctionCalls:
def _id(self, sak=0x00): def _id(self, sak=0x00):
return RawSession(device=_mock_device( return RawSession(device=_mock_device(