diff --git a/README.md b/README.md index a32448d..9bf53ba 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,7 @@ optional `TTLOCK_KEY_STORE`, default `~/.ttlock/keys.json`; a `.env` file is hon | `ttlock set-auto-lock ` | Admin eKey | Set the auto-lock delay in seconds (`0` disables it) | | `ttlock get-auto-lock-limits ` | Admin eKey | Show the min/max auto-lock delay this lock accepts | | `ttlock get-fingerprints ` | Admin eKey | List enrolled fingerprints — blind to cyclic (day-of-week / time-range) restrictions | +| `ttlock get-cards ` | Admin eKey | List enrolled IC cards/tags — blind to cyclic (day-of-week / time-range) restrictions | Every BLE command takes `-v` for debug logging. `` is a `lockId`, alias or MAC from `ttlock list`. @@ -193,6 +194,7 @@ Everything below is re-exported from the top-level `ttlock_ble` package. | `add_passcode(...)` / `delete_passcode(...)` / `clear_passcodes()` | Admin eKey | Manage keypad passcodes | | `get_passcodes()` | Admin eKey | Keypad passcodes the lock reports (`list[PasscodeEntry]`) — **not exhaustive**: a passcode never added through this library, and never yet used at the keypad, won't appear | | `get_fingerprints()` | Admin eKey | Enrolled fingerprints (`list[FingerprintEntry]`) — blind to cyclic (day-of-week / time-range) restrictions | +| `get_cards()` | Admin eKey | Enrolled IC cards/tags (`list[CardEntry]`) — blind to cyclic (day-of-week / time-range) restrictions | | `get_lock_sound()` | Admin eKey | Beep on/off and volume as the lock reports them (`LockSound`; `volume` is `None` on beeper-only hardware) | | `set_lock_sound(enabled)` | Admin eKey | Turn the keypad/lock beep on or off | | `set_lock_volume(level)` | Admin eKey | Set the keypad/lock beep volume, 1-5 or `LockVolume` (no-op on beeper-only hardware) | @@ -215,7 +217,7 @@ Everything below is re-exported from the top-level `ttlock_ble` package. ### Models & enums -- **Models:** `VirtualKey`, `LockVersion`, `SiteInfo`, `LockAdvertisement`, `LockEvent`, `LogEntry`, `DeviceInfo`, `DeviceProperties`, `DeviceFeatures`, `AutoLockLimits`, `LockSound`, `FingerprintEntry`, `PasscodeEntry`, `CyclicSchedule` +- **Models:** `VirtualKey`, `LockVersion`, `SiteInfo`, `LockAdvertisement`, `LockEvent`, `LogEntry`, `DeviceInfo`, `DeviceProperties`, `DeviceFeatures`, `AutoLockLimits`, `LockSound`, `FingerprintEntry`, `CardEntry`, `PasscodeEntry`, `CyclicSchedule` - **Enums:** `LockState`, `AutoLockOperate`, `KeyboardPwdType`, `LockFeature`, `LockVolume`, `LogOperate`, `PwdOperateType` - **Exceptions:** `TTLockError` (BLE / protocol), `CloudError` (cloud HTTP) diff --git a/src/ttlock_ble/__init__.py b/src/ttlock_ble/__init__.py index 0641a29..a595cbb 100644 --- a/src/ttlock_ble/__init__.py +++ b/src/ttlock_ble/__init__.py @@ -15,6 +15,7 @@ DeviceProperties — 6 confirmed TTLock-proprietary device properties AutoLockLimits — the lock's own min/max allowed auto-lock delay FingerprintEntry — one enrolled fingerprint (see get_fingerprints's caveat) + CardEntry — one enrolled IC card/tag (see get_cards's caveat) PasscodeEntry — one keypad passcode (see get_passcodes's scope caveat) CyclicSchedule — the day-of-week/time-window rule behind a CIRCLE passcode TTLockError — raised by `TTLockClient` on BLE / protocol failure @@ -41,6 +42,7 @@ from .exceptions import CloudError, TTLockError from .models import ( AutoLockLimits, + CardEntry, CyclicSchedule, DeviceFeatures, DeviceInfo, @@ -59,6 +61,7 @@ __all__ = [ "AutoLockLimits", "AutoLockOperate", + "CardEntry", "CloudError", "CyclicSchedule", "DeviceFeatures", diff --git a/src/ttlock_ble/cli.py b/src/ttlock_ble/cli.py index 4935ec1..8de351c 100644 --- a/src/ttlock_ble/cli.py +++ b/src/ttlock_ble/cli.py @@ -1,4 +1,4 @@ -"""Typer-powered `ttlock` CLI: locks, state, sound, device info, passcodes, fingerprints.""" +"""Typer-powered `ttlock` CLI: locks, state, sound, device info, passcodes, fingerprints, cards.""" from __future__ import annotations @@ -20,6 +20,7 @@ from .exceptions import CloudError from .models import ( AutoLockLimits, + CardEntry, DeviceFeatures, DeviceInfo, DeviceProperties, @@ -457,6 +458,28 @@ def get_fingerprints( ) +@app.command("get-cards") +def get_cards( + target: str = typer.Argument(..., help="lockId, alias, or MAC"), + verbose: bool = typer.Option(False, "-v"), +) -> None: + """List enrolled IC cards/tags (requires an admin eKey).""" + if verbose: + logging.basicConfig(level=logging.DEBUG) + key = _resolve_key(target) + entries = asyncio.run(_run_get_cards(key)) + if not entries: + typer.echo("no cards enrolled") + for entry in entries: + start = "not set" if entry.start_date is None else entry.start_date.isoformat(sep=" ") + end = "permanent" if entry.end_date is None else entry.end_date.isoformat(sep=" ") + typer.echo(f" id={entry.card_id.hex()} start={start} end={end}") + typer.echo( + "note: this cannot detect cyclic (day-of-week/time-range) restrictions - " + "a card above may still be limited to specific days/hours." + ) + + async def _run_unlock(key: VirtualKey) -> None: async with TTLockClient(key) as c: await c.unlock() @@ -542,6 +565,11 @@ async def _run_get_fingerprints(key: VirtualKey) -> list[FingerprintEntry]: return await c.get_fingerprints() +async def _run_get_cards(key: VirtualKey) -> list[CardEntry]: + async with TTLockClient(key) as c: + return await c.get_cards() + + if ( __name__ == "__main__" ): # pragma: no cover -- module entry point, exercised via the `ttlock` console script diff --git a/src/ttlock_ble/client.py b/src/ttlock_ble/client.py index a57d2b7..83b199f 100644 --- a/src/ttlock_ble/client.py +++ b/src/ttlock_ble/client.py @@ -23,6 +23,7 @@ from .exceptions import TTLockError from .models import ( AutoLockLimits, + CardEntry, DeviceFeatures, DeviceInfo, DeviceProperties, @@ -53,6 +54,7 @@ # what any firmware can hold - see `get_fingerprints` and `get_passcodes`. _MAX_FINGERPRINT_ENTRIES = 300 _MAX_PASSCODE_ENTRIES = 300 +_MAX_CARD_ENTRIES = 300 class TTLockClient: @@ -601,6 +603,59 @@ async def get_fingerprints(self) -> list[FingerprintEntry]: log.info("fetched %d fingerprint(s)", len(entries)) return entries + async def get_cards(self) -> list[CardEntry]: + """Read every enrolled IC card (tag) via the confirmed indexed CMD 0x05/0x01 query. + + Admin-gated - see `set_lock_sound`. + + Loops the index from 0, one BLE round-trip per card, over a + single connection and a single admin handshake - mirrors + `get_fingerprints` exactly. Stops cleanly at the lock's own + end-of-list response. Raises `TTLockError` if the lock never + sends it within `_MAX_CARD_ENTRIES` queries, rather than + silently returning a possibly-incomplete list. + + Known limitation: this cannot detect whether a card also has a + cyclic (day-of-week/time-range) restriction - that data lives in + a separate mechanism (CMD 0x70) this method doesn't query, and + that is not implemented anywhere in this library yet for any + credential type. A card returned here as permanent or timed may + in practice also be restricted to specific days/hours. Do not + treat the absence of that information as confirmation a card is + unrestricted. + + The 4-byte `card_id` path, a multi-card listing (four cards, + mixing timed and permanent entries), the zero-card case and the + end-of-list response are all confirmed on hardware. Only the + 8-byte `card_id` path is not - it is derived from the official + SDK's own length threshold only - see + `commands.card.parse_card_list_response` for how both are + decoded. + """ + async with self._command_lock: + await self._admin_handshake() + entries: list[CardEntry] = [] + for index in range(_MAX_CARD_ENTRIES): + resp = await self._transport.exchange( + self._frame(cmd.CMD_MANAGE_IC_CARD, cmd.payload_card_list(index)) + ) + plain = self._decrypt_response(resp, "get_cards") + log.debug("card list response plaintext: %s", plain.hex()) + try: + entry = cmd.parse_card_list_response(plain) + except (RuntimeError, ValueError) as error: + raise TTLockError(f"Failed to get_cards: {error}") from error + if entry is None: + break + entries.append(entry) + else: + raise TTLockError( + f"Failed to get_cards: exceeded safety cap of " + f"{_MAX_CARD_ENTRIES} entries without an end-of-list response" + ) + log.info("fetched %d card(s)", len(entries)) + return entries + async def get_lock_sound(self) -> LockSound: """Read whether the keypad/lock beep is on and, when reported, its volume. @@ -926,6 +981,11 @@ async def _admin_handshake(self) -> None: confirmed via testing without the handshake - see its own docstring. + `get_cards` requires it too, confirmed by testing: an IC_SEARCH + query sent on a fresh connection without this handshake was + refused by the lock with a clean failure status, not a timeout + (tested on one lock model) - see its own docstring. + Checks the admin password before touching the BLE link: it is `""` on a key that never carried one, and `payload_check_admin` would otherwise raise a raw `ValueError` from `int("")` - this diff --git a/src/ttlock_ble/commands/__init__.py b/src/ttlock_ble/commands/__init__.py index f5f2431..c34753f 100644 --- a/src/ttlock_ble/commands/__init__.py +++ b/src/ttlock_ble/commands/__init__.py @@ -6,7 +6,7 @@ One submodule per command family — `session` for the handshake, `control` for the bolt, `clock`, `auto_lock`, `passcode`, `passcode_list`, -`operate_log`, `lock_sound`, `fingerprint`, `device_feature` and +`operate_log`, `lock_sound`, `card`, `fingerprint`, `device_feature` and `device_properties` for the rest — over the shared `opcodes`, `envelope` and `encoding` primitives. """ @@ -19,6 +19,7 @@ payload_auto_lock_search, payload_auto_lock_set, ) +from .card import parse_card_list_response, payload_card_list from .clock import parse_get_lock_time_response, payload_get_lock_time, payload_time_calibrate from .control import ( LOCKED, @@ -65,6 +66,7 @@ CMD_INIT_PASSWORDS, CMD_LOCK, CMD_MANAGE_FINGERPRINT, + CMD_MANAGE_IC_CARD, CMD_MANAGE_KEYBOARD_PASSWORD, CMD_QUERY_STATE, CMD_RESPONSE, @@ -104,6 +106,7 @@ "CMD_INIT_PASSWORDS", "CMD_LOCK", "CMD_MANAGE_FINGERPRINT", + "CMD_MANAGE_IC_CARD", "CMD_MANAGE_KEYBOARD_PASSWORD", "CMD_QUERY_STATE", "CMD_RESPONSE", @@ -121,6 +124,7 @@ "VENDOR", "parse_auto_lock_limits_response", "parse_auto_lock_response", + "parse_card_list_response", "parse_check_admin_response", "parse_check_user_time_response", "parse_device_feature_response", @@ -137,6 +141,7 @@ "parse_state_battery", "payload_auto_lock_search", "payload_auto_lock_set", + "payload_card_list", "payload_check_admin", "payload_check_random", "payload_check_user_time", diff --git a/src/ttlock_ble/commands/card.py b/src/ttlock_ble/commands/card.py new file mode 100644 index 0000000..8f5c108 --- /dev/null +++ b/src/ttlock_ble/commands/card.py @@ -0,0 +1,99 @@ +"""IC card (tag) listing - CMD 0x05, sub-op 0x01 (IC_SEARCH, the confirmed indexed query). + +Reverse-engineered from device traffic. Only the list sub-op is confirmed; +this module deliberately does not model enroll/delete/edit-validity or any +other sub-op that may share CMD 0x05 - see `opcodes.CMD_MANAGE_IC_CARD`. +Read-only, like `fingerprint`: adding, deleting or editing a card is out +of scope here. + +Critical limitation, worth repeating at the protocol layer because it is +easy to miss at the call site: this query has zero visibility into cyclic +(day-of-week/time-range) restrictions. CMD 0x70, the mechanism that would +carry that data, is not implemented anywhere in this library yet, for any +credential type. A card decoded here as permanent or timed may in +practice also be cyclically restricted - see `models.CardEntry`. +""" + +from __future__ import annotations + +from ..models import CardEntry +from .encoding import decode_date5 +from .envelope import RESPONSE_SUCCESS, parse_response_status +from .fingerprint import END_DATE_SENTINEL, START_DATE_SENTINEL + +_LIST_OPERATION = 0x01 + +# A SUCCESS response's data is [battery][op_echo][position:2][card_id][start:5][end:5]. +# `card_id`'s length is not confirmed to be the same for every lock: the +# official SDK reads 8 bytes when its own id+dates buffer (position and +# everything after it, excluding battery and op_echo) is exactly 20 bytes, +# and 4 otherwise. Translated into this module's full `data` (battery and +# op_echo included), that SDK threshold lands on a *different* total +# length than 20 - the two lengths below, not the SDK's own literal 20. +_SHORT_ENTRY_DATA_LENGTH = 18 # 4-byte card_id - confirmed on real hardware +_LONG_ENTRY_DATA_LENGTH = 22 # 8-byte card_id - from the SDK, not independently confirmed +_SHORT_CARD_ID_LENGTH = 4 +_LONG_CARD_ID_LENGTH = 8 + +# Dates use the same 5-byte decimal encoding and the same sentinels as +# fingerprints (re-exported above, not redefined) - see `commands.fingerprint`. + +# End-of-list is a SUCCESS response whose data is [battery][op_echo][0xFF][0xFF] +# (captured on real hardware as the data `55 01 ff ff`), not a FAILED status - +# mirrors `commands.fingerprint`'s end-of-list shape exactly. +_END_OF_LIST_DATA_LENGTH = 4 +_END_OF_LIST_TAIL = bytes([_LIST_OPERATION, 0xFF, 0xFF]) + + +def payload_card_list(index: int) -> bytes: + """Build the CMD 0x05 sub-op 0x01 payload for one indexed card query. + + `index` starts at 0 and increments by 1 per call - each call returns + at most one enrolled card, or the end-of-list response. Mirrors + `payload_fingerprint_list`'s indexing exactly. + """ + return bytes([_LIST_OPERATION]) + index.to_bytes(2, "big") + + +def parse_card_list_response(plaintext: bytes) -> CardEntry | None: + """Decode one CMD 0x05/0x01 response. Returns `None` at the end-of-list marker. + + Raises `RuntimeError` on a FAILED status. Raises `ValueError` if a + SUCCESS response's data matches neither confirmed entry length. + + Wire layout of a SUCCESS response's data, confirmed on real hardware + for the 4-byte `card_id` case: + + [0] battery percentage - not exposed on `CardEntry` + [1] op_echo (always 0x01) + [2:4] position (index + 1) - discarded; not a total count, + mirrors `parse_fingerprint_list_response`'s `position` + exactly, including the same caution against reading + more into it than that + [4:4+N] card_id (N = 4 or 8 bytes, see the module docstring) + [4+N:9+N] start_date (5-byte decimal date) + [9+N:14+N] end_date (5-byte decimal date) + + Unlike `FingerprintEntry`, there is no `slot` field here - the + confirmed capture's byte count leaves no room for one between + `card_id` and the dates. + """ + _cmd_echo, status, data = parse_response_status(plaintext) + if status != RESPONSE_SUCCESS: + raise RuntimeError(f"card list FAILED: status={status:#x} err={data.hex()}") + if len(data) == _END_OF_LIST_DATA_LENGTH and data[1:] == _END_OF_LIST_TAIL: + return None + if len(data) == _LONG_ENTRY_DATA_LENGTH: + card_id_length = _LONG_CARD_ID_LENGTH + elif len(data) == _SHORT_ENTRY_DATA_LENGTH: + card_id_length = _SHORT_CARD_ID_LENGTH + else: + raise ValueError(f"card list unexpected data length {len(data)}: {plaintext.hex()}") + card_id_end = 4 + card_id_length + start_date_raw = data[card_id_end : card_id_end + 5] + end_date_raw = data[card_id_end + 5 : card_id_end + 10] + return CardEntry( + card_id=data[4:card_id_end], + start_date=None if start_date_raw == START_DATE_SENTINEL else decode_date5(start_date_raw), + end_date=None if end_date_raw == END_DATE_SENTINEL else decode_date5(end_date_raw), + ) diff --git a/src/ttlock_ble/commands/opcodes.py b/src/ttlock_ble/commands/opcodes.py index 91c000e..e44e8a1 100644 --- a/src/ttlock_ble/commands/opcodes.py +++ b/src/ttlock_ble/commands/opcodes.py @@ -8,6 +8,7 @@ CMD_SEARCH_DEVICE_FEATURE = 0x01 CMD_MANAGE_KEYBOARD_PASSWORD = 0x03 +CMD_MANAGE_IC_CARD = 0x05 # COMM_IC_MANAGE; only the list sub-op (0x01) is implemented CMD_MANAGE_FINGERPRINT = 0x06 # COMM_FR_MANAGE; only the list sub-op (0x06) is implemented CMD_GET_PASSCODES = 0x07 # COMM_PWD_LIST CMD_QUERY_STATE = 0x14 diff --git a/src/ttlock_ble/models/__init__.py b/src/ttlock_ble/models/__init__.py index 6f3e8a7..3f2e0a9 100644 --- a/src/ttlock_ble/models/__init__.py +++ b/src/ttlock_ble/models/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from .auto_lock_limits import AutoLockLimits +from .card_entry import CardEntry from .cloud_credentials import CloudCredentials from .cyclic_schedule import CyclicSchedule from .device_features import DeviceFeatures @@ -20,6 +21,7 @@ __all__ = [ "AutoLockLimits", + "CardEntry", "CloudCredentials", "CyclicSchedule", "DeviceFeatures", diff --git a/src/ttlock_ble/models/card_entry.py b/src/ttlock_ble/models/card_entry.py new file mode 100644 index 0000000..3e3b697 --- /dev/null +++ b/src/ttlock_ble/models/card_entry.py @@ -0,0 +1,50 @@ +"""CardEntry: one enrolled IC card (tag), as returned by `TTLockClient.get_cards`.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import datetime as dt + + +@dataclass(frozen=True, slots=True) +class CardEntry: + """One enrolled IC card (tag), decoded from the indexed CMD 0x05/0x01 query. + + Known limitation: this cannot detect whether a card also has a + cyclic (day-of-week/time-range) restriction - CMD 0x70, the + mechanism that carries that data for every credential type, is not + implemented anywhere in this library yet. A card shown here as + permanent or timed may in practice also be restricted to specific + days/hours. Do not treat the absence of that information as + confirmation a card is unrestricted. + """ + + card_id: bytes + start_date: dt.datetime | None + end_date: dt.datetime | None + + @property + def is_permanent(self) -> bool: + """True if this card has no expiry date. + + Derived from `end_date` being the sentinel value observed on every + card examined so far that the app itself labels "Permanent". Says + nothing about + cyclic (day-of-week/time-range) restrictions - a permanent card + can still be cyclically restricted via a separate mechanism + this class doesn't cover; see the class docstring. + """ + return self.end_date is None + + @property + def has_explicit_start(self) -> bool: + """True if a start date was ever set on this card. + + `False` means it is still at the "never explicitly set" sentinel + observed, so far, on every fresh permanent or newly-enrolled + timed card. + """ + return self.start_date is not None diff --git a/tests/test_cli.py b/tests/test_cli.py index b51d5b7..b940280 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -18,6 +18,7 @@ from tests.conftest import make_virtual_key from ttlock_ble import ( AutoLockLimits, + CardEntry, CyclicSchedule, DeviceFeatures, DeviceInfo, @@ -591,3 +592,40 @@ def test_get_fingerprints_empty(self, cli_app, monkeypatch) -> None: result = runner.invoke(cli_module.app, ["get-fingerprints", "2"]) assert result.exit_code == 0, result.output assert "no fingerprints enrolled" in result.output + + def test_get_cards(self, cli_app, monkeypatch) -> None: + cli_module, store = cli_app + _write_keys(store) + client = MagicMock() + client.get_cards = AsyncMock( + return_value=[ + CardEntry( + card_id=bytes([0x11, 0x22, 0x33, 0x44]), + start_date=dt.datetime(2026, 3, 1, 8, 0), # noqa: DTZ001 + end_date=None, + ), + CardEntry( + card_id=bytes([0x11, 0x22, 0x33, 0x45]), + start_date=None, + end_date=dt.datetime(2026, 12, 31, 23, 59), # noqa: DTZ001 + ), + ] + ) + self._patch_client(cli_module, monkeypatch, client) + result = runner.invoke(cli_module.app, ["get-cards", "2", "-v"]) + assert result.exit_code == 0, result.output + assert "id=11223344" in result.output + assert "end=permanent" in result.output + assert "id=11223345" in result.output + assert "start=not set" in result.output + assert "cyclic" in result.output + + def test_get_cards_empty(self, cli_app, monkeypatch) -> None: + cli_module, store = cli_app + _write_keys(store) + client = MagicMock() + client.get_cards = AsyncMock(return_value=[]) + self._patch_client(cli_module, monkeypatch, client) + result = runner.invoke(cli_module.app, ["get-cards", "2"]) + assert result.exit_code == 0, result.output + assert "no cards enrolled" in result.output diff --git a/tests/test_client_ble.py b/tests/test_client_ble.py index 9d17b4f..a14830c 100644 --- a/tests/test_client_ble.py +++ b/tests/test_client_ble.py @@ -889,6 +889,129 @@ async def test_get_fingerprints_exceeds_safety_cap_raises( with pytest.raises(TTLockError, match="exceeded safety cap"): await client.get_fingerprints() + async def test_get_cards_mixed_permanent_and_timed(self, patched_connect) -> None: + client, fake, key = await self._connected(patched_connect) + # Mirrors the real captures: a permanent card and a timed one, both + # with a synthetic card id substituted for the real one. + permanent = _card_entry_plain( + position=1, + card_id=bytes([0x11, 0x22, 0x33, 0x44]), + start=cmd.START_DATE_SENTINEL, + end=cmd.END_DATE_SENTINEL, + ) + timed = _card_entry_plain( + position=2, + card_id=bytes([0x11, 0x22, 0x33, 0x45]), + start=cmd.START_DATE_SENTINEL, + end=bytes([29, 9, 29, 20, 42]), + ) + fake.reply_for_next = [ + _resp_frame(key, cmd.CMD_CHECK_ADMIN, _check_admin_plain()), + _resp_frame(key, cmd.CMD_CHECK_RANDOM, _status_plain(cmd.CMD_CHECK_RANDOM)), + _resp_frame(key, cmd.CMD_MANAGE_IC_CARD, permanent), + _resp_frame(key, cmd.CMD_MANAGE_IC_CARD, timed), + _resp_frame(key, cmd.CMD_MANAGE_IC_CARD, _CARD_END_OF_LIST), + ] + + entries = await client.get_cards() + + assert len(entries) == 2 + first, second = entries + + assert first.card_id == bytes([0x11, 0x22, 0x33, 0x44]) + assert first.start_date is None + assert first.end_date is None + assert first.is_permanent + + assert second.card_id == bytes([0x11, 0x22, 0x33, 0x45]) + assert second.start_date is None + assert second.end_date == dt.datetime(2029, 9, 29, 20, 42) # noqa: DTZ001 + assert not second.is_permanent + + async def test_get_cards_empty_list(self, patched_connect) -> None: + client, fake, key = await self._connected(patched_connect) + fake.reply_for_next = [ + _resp_frame(key, cmd.CMD_CHECK_ADMIN, _check_admin_plain()), + _resp_frame(key, cmd.CMD_CHECK_RANDOM, _status_plain(cmd.CMD_CHECK_RANDOM)), + _resp_frame(key, cmd.CMD_MANAGE_IC_CARD, _CARD_END_OF_LIST), + ] + + entries = await client.get_cards() + + assert entries == [] + + async def test_get_cards_end_of_list_matches_captured_hardware_bytes( + self, patched_connect + ) -> None: + # Real capture of the end-of-list marker following a lock's one + # enrolled card: data = `55 01 ff ff`. The same marker shape at + # index 0 (zero enrolled cards) is also confirmed on hardware - + # see test_get_cards_empty_list. + client, fake, key = await self._connected(patched_connect) + captured_plain = bytes([cmd.CMD_MANAGE_IC_CARD, cmd.RESPONSE_SUCCESS]) + bytes.fromhex( + "5501ffff" + ) + fake.reply_for_next = [ + _resp_frame(key, cmd.CMD_CHECK_ADMIN, _check_admin_plain()), + _resp_frame(key, cmd.CMD_CHECK_RANDOM, _status_plain(cmd.CMD_CHECK_RANDOM)), + _resp_frame(key, cmd.CMD_MANAGE_IC_CARD, captured_plain), + ] + + entries = await client.get_cards() + + assert entries == [] + + async def test_get_cards_admin_check_rejected_raises(self, patched_connect) -> None: + client, fake, key = await self._connected(patched_connect) + fake.reply_for_next = [ + _resp_frame( + key, + cmd.CMD_CHECK_ADMIN, + _status_plain(cmd.CMD_CHECK_ADMIN, cmd.RESPONSE_FAILED) + b"\xff", + ) + ] + with pytest.raises(TTLockError, match="Failed to authorize as admin"): + await client.get_cards() + + async def test_get_cards_rejected_mid_scan_raises(self, patched_connect) -> None: + client, fake, key = await self._connected(patched_connect) + first_entry = _card_entry_plain( + position=1, + card_id=bytes([0x11, 0x22, 0x33, 0x44]), + start=cmd.START_DATE_SENTINEL, + end=cmd.END_DATE_SENTINEL, + ) + fake.reply_for_next = [ + _resp_frame(key, cmd.CMD_CHECK_ADMIN, _check_admin_plain()), + _resp_frame(key, cmd.CMD_CHECK_RANDOM, _status_plain(cmd.CMD_CHECK_RANDOM)), + _resp_frame(key, cmd.CMD_MANAGE_IC_CARD, first_entry), + _resp_frame( + key, + cmd.CMD_MANAGE_IC_CARD, + bytes([cmd.CMD_MANAGE_IC_CARD, cmd.RESPONSE_FAILED, 0x02]), + ), + ] + with pytest.raises(TTLockError, match="Failed to get_cards"): + await client.get_cards() + + async def test_get_cards_exceeds_safety_cap_raises(self, patched_connect, monkeypatch) -> None: + monkeypatch.setattr(client_mod, "_MAX_CARD_ENTRIES", 2) + client, fake, key = await self._connected(patched_connect) + entry = _card_entry_plain( + position=1, + card_id=bytes([0x11, 0x22, 0x33, 0x44]), + start=cmd.START_DATE_SENTINEL, + end=cmd.END_DATE_SENTINEL, + ) + fake.reply_for_next = [ + _resp_frame(key, cmd.CMD_CHECK_ADMIN, _check_admin_plain()), + _resp_frame(key, cmd.CMD_CHECK_RANDOM, _status_plain(cmd.CMD_CHECK_RANDOM)), + _resp_frame(key, cmd.CMD_MANAGE_IC_CARD, entry), + _resp_frame(key, cmd.CMD_MANAGE_IC_CARD, entry), + ] + with pytest.raises(TTLockError, match="exceeded safety cap"): + await client.get_cards() + async def test_get_lock_sound_with_volume(self, patched_connect) -> None: # Layout from the SDK's audioManage parser: [battery][op=1][sound][volume]. client, fake, key = await self._connected(patched_connect) @@ -1535,3 +1658,19 @@ def _fingerprint_entry_plain( _FINGERPRINT_END_OF_LIST = bytes( [cmd.CMD_MANAGE_FINGERPRINT, cmd.RESPONSE_SUCCESS, 0x64, cmd.CMD_MANAGE_FINGERPRINT, 0xFF, 0xFF] ) + + +def _card_entry_plain(*, position: int, card_id: bytes, start: bytes, end: bytes) -> bytes: + """Build one CMD 0x05/0x01 SUCCESS response for a non-empty entry. + + Matches the confirmed wire layout - see `parse_card_list_response`'s + docstring. + """ + data = bytes([0x64, 0x01]) + position.to_bytes(2, "big") + card_id + start + end + return bytes([cmd.CMD_MANAGE_IC_CARD, cmd.RESPONSE_SUCCESS]) + data + + +# Real capture of the end-of-list marker following a lock's one enrolled +# card: SUCCESS, data = `55 01 ff ff`. The same marker shape at index 0 +# (zero enrolled cards) is also confirmed on hardware. +_CARD_END_OF_LIST = bytes([cmd.CMD_MANAGE_IC_CARD, cmd.RESPONSE_SUCCESS, 0x55, 0x01, 0xFF, 0xFF]) diff --git a/tests/test_commands.py b/tests/test_commands.py index efb5e7c..ed3ca70 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -109,6 +109,10 @@ def test_fingerprint_list_layout(self) -> None: assert cmd.payload_fingerprint_list(0) == bytes([0x06, 0x00, 0x00]) assert cmd.payload_fingerprint_list(300) == bytes([0x06, 0x01, 0x2C]) + def test_card_list_layout(self) -> None: + assert cmd.payload_card_list(0) == bytes([0x01, 0x00, 0x00]) + assert cmd.payload_card_list(300) == bytes([0x01, 0x01, 0x2C]) + class TestParsers: def test_response_status_too_short(self) -> None: @@ -346,6 +350,119 @@ def test_sentinel_constants_decode_to_the_documented_dates(self) -> None: assert decode_date5(cmd.END_DATE_SENTINEL) == dt.datetime(2099, 1, 1, 0, 0) # noqa: DTZ001 +def _card_response(*, position: int, card_id: bytes, start: bytes, end: bytes) -> bytes: + """Build a full CMD 0x05/0x01 SUCCESS response matching the confirmed wire layout.""" + data = bytes([0x64, 0x01]) + position.to_bytes(2, "big") + card_id + start + end + return bytes([cmd.CMD_MANAGE_IC_CARD, cmd.RESPONSE_SUCCESS]) + data + + +class TestCardList: + """Byte layouts below mirror real hardware captures, with the real card id + (a cloneable credential) replaced by a synthetic one of the same length. + """ + + def test_end_of_list_returns_none(self) -> None: + # Real capture of the end-of-list marker following a lock's one + # enrolled card: SUCCESS status, data = [battery][op_echo=0x01] + # [0xFF][0xFF] (`55 01 ff ff`). The same marker at index 0 (zero + # enrolled cards) is also confirmed on hardware - see + # test_get_cards_empty_list. + plain = bytes([cmd.CMD_MANAGE_IC_CARD, cmd.RESPONSE_SUCCESS, 0x55, 0x01, 0xFF, 0xFF]) + assert cmd.parse_card_list_response(plain) is None + + def test_end_of_list_ignores_the_battery_value(self) -> None: + plain = bytes([cmd.CMD_MANAGE_IC_CARD, cmd.RESPONSE_SUCCESS, 0x00, 0x01, 0xFF, 0xFF]) + assert cmd.parse_card_list_response(plain) is None + + def test_permanent_entry(self) -> None: + # Mirrors a real capture of a permanent card, with the card id + # replaced by a synthetic value. + plain = _card_response( + position=1, + card_id=bytes([0x11, 0x22, 0x33, 0x44]), + start=cmd.START_DATE_SENTINEL, + end=cmd.END_DATE_SENTINEL, + ) + entry = cmd.parse_card_list_response(plain) + assert entry is not None + assert entry.card_id == bytes([0x11, 0x22, 0x33, 0x44]) + assert entry.start_date is None + assert entry.end_date is None + assert entry.is_permanent + assert not entry.has_explicit_start + + def test_timed_entry_with_sentinel_start(self) -> None: + # Mirrors a real capture of a timed card (dated end 2029-09-29 + # 20:42), with the card id replaced by a synthetic value. + plain = _card_response( + position=1, + card_id=bytes([0x11, 0x22, 0x33, 0x44]), + start=cmd.START_DATE_SENTINEL, + end=bytes([0x1D, 0x09, 0x1D, 0x14, 0x2A]), + ) + entry = cmd.parse_card_list_response(plain) + assert entry is not None + assert entry.start_date is None + assert entry.end_date == dt.datetime(2029, 9, 29, 20, 42) # noqa: DTZ001 + assert not entry.is_permanent + assert not entry.has_explicit_start + + def test_entry_position_does_not_leak_into_card_id(self) -> None: + # Regression: mirrors the equivalent fingerprint guard against reading + # the layout without the leading battery byte. + plain = _card_response( + position=7, + card_id=bytes([0xAA, 0xBB, 0xCC, 0xDD]), + start=cmd.START_DATE_SENTINEL, + end=cmd.END_DATE_SENTINEL, + ) + entry = cmd.parse_card_list_response(plain) + assert entry is not None + assert entry.card_id == bytes([0xAA, 0xBB, 0xCC, 0xDD]) + + def test_long_card_id_entry_is_not_hardware_confirmed(self) -> None: + # The 8-byte card_id path is derived from the official SDK's own + # length threshold, not from a real capture - see commands.card. + data = ( + bytes([0x64, 0x01, 0x00, 0x01]) + + bytes([0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88]) + + cmd.START_DATE_SENTINEL + + cmd.END_DATE_SENTINEL + ) + assert len(data) == 22 + plain = bytes([cmd.CMD_MANAGE_IC_CARD, cmd.RESPONSE_SUCCESS]) + data + entry = cmd.parse_card_list_response(plain) + assert entry is not None + assert entry.card_id == bytes([0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88]) + assert entry.is_permanent + + def test_failure_raises(self) -> None: + plain = bytes([cmd.CMD_MANAGE_IC_CARD, cmd.RESPONSE_FAILED, 0x02]) + with pytest.raises(RuntimeError, match="FAILED"): + cmd.parse_card_list_response(plain) + + def test_short_success_payload_raises(self) -> None: + plain = bytes([cmd.CMD_MANAGE_IC_CARD, cmd.RESPONSE_SUCCESS, 0x64, 0x01, 0x00]) + with pytest.raises(ValueError, match="unexpected data length 3"): + cmd.parse_card_list_response(plain) + + def test_sdk_literal_threshold_length_raises(self) -> None: + # Regression: 20 bytes is the SDK's own literal id+dates buffer + # threshold for picking the 8-byte card_id, but it is not a valid + # *total* `data` length in this module's framing (18 or 22) - + # this must still raise, not be silently accepted. + data = ( + bytes([0x64, 0x01, 0x00, 0x01]) + + bytes([0x11, 0x22, 0x33, 0x44, 0x55, 0x66]) + + cmd.START_DATE_SENTINEL + + cmd.END_DATE_SENTINEL + ) + assert len(data) == 20 + plain = bytes([cmd.CMD_MANAGE_IC_CARD, cmd.RESPONSE_SUCCESS]) + data + with pytest.raises(ValueError, match="unexpected data length 20"): + cmd.parse_card_list_response(plain) + + class TestDeviceFeature: """Layout from the SDK's parser: [battery][word0 BE][word1 BE]…, low word first.""" diff --git a/tests/test_public_api.py b/tests/test_public_api.py index c0e3373..83b52e2 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -16,6 +16,7 @@ { "AutoLockLimits", "AutoLockOperate", + "CardEntry", "CloudError", "CyclicSchedule", "DeviceFeatures", @@ -54,6 +55,7 @@ "from_ble_device", "get_auto_lock_limits", "get_auto_lock_time", + "get_cards", "get_device_features", "get_device_info", "get_fingerprints",