Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ optional `TTLOCK_KEY_STORE`, default `~/.ttlock/keys.json`; a `.env` file is hon
| `ttlock set-auto-lock <lock> <seconds>` | Admin eKey | Set the auto-lock delay in seconds (`0` disables it) |
| `ttlock get-auto-lock-limits <lock>` | Admin eKey | Show the min/max auto-lock delay this lock accepts |
| `ttlock get-fingerprints <lock>` | Admin eKey | List enrolled fingerprints — blind to cyclic (day-of-week / time-range) restrictions |
| `ttlock get-cards <lock>` | Admin eKey | List enrolled IC cards/tags — blind to cyclic (day-of-week / time-range) restrictions |

Every BLE command takes `-v` for debug logging. `<lock>` is a `lockId`, alias or MAC from
`ttlock list`.
Expand Down Expand Up @@ -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) |
Expand All @@ -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)

Expand Down
3 changes: 3 additions & 0 deletions src/ttlock_ble/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -41,6 +42,7 @@
from .exceptions import CloudError, TTLockError
from .models import (
AutoLockLimits,
CardEntry,
CyclicSchedule,
DeviceFeatures,
DeviceInfo,
Expand All @@ -59,6 +61,7 @@
__all__ = [
"AutoLockLimits",
"AutoLockOperate",
"CardEntry",
"CloudError",
"CyclicSchedule",
"DeviceFeatures",
Expand Down
30 changes: 29 additions & 1 deletion src/ttlock_ble/cli.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -20,6 +20,7 @@
from .exceptions import CloudError
from .models import (
AutoLockLimits,
CardEntry,
DeviceFeatures,
DeviceInfo,
DeviceProperties,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions src/ttlock_ble/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from .exceptions import TTLockError
from .models import (
AutoLockLimits,
CardEntry,
DeviceFeatures,
DeviceInfo,
DeviceProperties,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/ttlock_ble/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
99 changes: 99 additions & 0 deletions src/ttlock_ble/commands/card.py
Original file line number Diff line number Diff line change
@@ -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),
)
1 change: 1 addition & 0 deletions src/ttlock_ble/commands/opcodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/ttlock_ble/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,6 +21,7 @@

__all__ = [
"AutoLockLimits",
"CardEntry",
"CloudCredentials",
"CyclicSchedule",
"DeviceFeatures",
Expand Down
Loading
Loading