opensb-keypad 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opensb/keypad/__init__.py +65 -0
- opensb/keypad/advertisement.py +57 -0
- opensb/keypad/const.py +32 -0
- opensb/keypad/device.py +336 -0
- opensb/keypad/discovery.py +35 -0
- opensb/keypad/enums.py +139 -0
- opensb/keypad/errors.py +12 -0
- opensb/keypad/frames.py +260 -0
- opensb/keypad/models.py +284 -0
- opensb/keypad/py.typed +0 -0
- opensb/keypad/replies.py +155 -0
- opensb_keypad-0.0.1.dist-info/METADATA +23 -0
- opensb_keypad-0.0.1.dist-info/RECORD +15 -0
- opensb_keypad-0.0.1.dist-info/WHEEL +4 -0
- opensb_keypad-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""The SwitchBot Keypad Touch: passcodes, cards, fingerprints and its own log."""
|
|
2
|
+
|
|
3
|
+
from opensb.keypad.advertisement import is_keypad, parse_advertisement, parse_bleak_advertisement
|
|
4
|
+
from opensb.keypad.const import SLOT_AUTO, SLOT_COUNT
|
|
5
|
+
from opensb.keypad.device import Keypad
|
|
6
|
+
from opensb.keypad.discovery import DiscoveredKeypad, discover
|
|
7
|
+
from opensb.keypad.enums import (
|
|
8
|
+
AlarmAction,
|
|
9
|
+
Backlight,
|
|
10
|
+
CredentialKind,
|
|
11
|
+
CredentialStatus,
|
|
12
|
+
FingerType,
|
|
13
|
+
LockAction,
|
|
14
|
+
LockButton,
|
|
15
|
+
LockoutKind,
|
|
16
|
+
LogSource,
|
|
17
|
+
NfcType,
|
|
18
|
+
PasswordType,
|
|
19
|
+
Switch,
|
|
20
|
+
UnlockAction,
|
|
21
|
+
)
|
|
22
|
+
from opensb.keypad.errors import EnrolmentError
|
|
23
|
+
from opensb.keypad.models import (
|
|
24
|
+
Advertisement,
|
|
25
|
+
DeviceInfo,
|
|
26
|
+
Enrolment,
|
|
27
|
+
ExecutionStatus,
|
|
28
|
+
LogEntry,
|
|
29
|
+
NfcPacket,
|
|
30
|
+
StoredCard,
|
|
31
|
+
StoredPassword,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"SLOT_AUTO",
|
|
36
|
+
"SLOT_COUNT",
|
|
37
|
+
"Advertisement",
|
|
38
|
+
"AlarmAction",
|
|
39
|
+
"Backlight",
|
|
40
|
+
"CredentialKind",
|
|
41
|
+
"CredentialStatus",
|
|
42
|
+
"DeviceInfo",
|
|
43
|
+
"DiscoveredKeypad",
|
|
44
|
+
"Enrolment",
|
|
45
|
+
"EnrolmentError",
|
|
46
|
+
"ExecutionStatus",
|
|
47
|
+
"FingerType",
|
|
48
|
+
"Keypad",
|
|
49
|
+
"LockAction",
|
|
50
|
+
"LockButton",
|
|
51
|
+
"LockoutKind",
|
|
52
|
+
"LogEntry",
|
|
53
|
+
"LogSource",
|
|
54
|
+
"NfcPacket",
|
|
55
|
+
"NfcType",
|
|
56
|
+
"PasswordType",
|
|
57
|
+
"StoredCard",
|
|
58
|
+
"StoredPassword",
|
|
59
|
+
"Switch",
|
|
60
|
+
"UnlockAction",
|
|
61
|
+
"discover",
|
|
62
|
+
"is_keypad",
|
|
63
|
+
"parse_advertisement",
|
|
64
|
+
"parse_bleak_advertisement",
|
|
65
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Decode what the keypad broadcasts. No key needed, no connection made."""
|
|
2
|
+
|
|
3
|
+
from opensb.ble.const import COMPANY_ID, SERVICE_UUID
|
|
4
|
+
from opensb.ble.errors import ProtocolError
|
|
5
|
+
from opensb.keypad.const import DEVICE_TYPE, DEVICE_TYPE_PAIRING
|
|
6
|
+
from opensb.keypad.models import Advertisement
|
|
7
|
+
|
|
8
|
+
KEYPAD_DEVICE_TYPES = frozenset({DEVICE_TYPE, DEVICE_TYPE_PAIRING})
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def is_keypad(service_data: bytes) -> bool:
|
|
12
|
+
"""Whether this 0xFD3D service data came from a Keypad Touch."""
|
|
13
|
+
return len(service_data) >= 3 and (service_data[0] & 0x7F) in KEYPAD_DEVICE_TYPES
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_advertisement(service_data: bytes, manufacturer_data: bytes) -> Advertisement:
|
|
17
|
+
"""Decode a Keypad Touch advertisement.
|
|
18
|
+
|
|
19
|
+
`service_data` is the 0xFD3D payload, `manufacturer_data` the 0x0969 one. Byte 9
|
|
20
|
+
carries both the duress alarms and the lockout flags, and the category bits in
|
|
21
|
+
byte 8 say which set is live.
|
|
22
|
+
"""
|
|
23
|
+
if not is_keypad(service_data):
|
|
24
|
+
raise ProtocolError(f"not Keypad Touch service data: {service_data.hex()}")
|
|
25
|
+
if len(manufacturer_data) < 10:
|
|
26
|
+
raise ProtocolError(f"keypad manufacturer data too short: {manufacturer_data.hex()}")
|
|
27
|
+
|
|
28
|
+
charge, category, alerts = manufacturer_data[7], manufacturer_data[8], manufacturer_data[9]
|
|
29
|
+
lockouts_live = bool(category & 0x01)
|
|
30
|
+
duress_live = bool(category & 0x04)
|
|
31
|
+
lockouts = alerts >> 4
|
|
32
|
+
|
|
33
|
+
return Advertisement(
|
|
34
|
+
battery=service_data[2] & 0x7F,
|
|
35
|
+
charging=bool(charge & 0x80),
|
|
36
|
+
tamper_alert=bool(category & 0x02),
|
|
37
|
+
urgent_password_alert=duress_live and bool(alerts & 0x01),
|
|
38
|
+
urgent_nfc_alert=duress_live and bool(alerts & 0x02),
|
|
39
|
+
urgent_finger_alert=duress_live and bool(alerts & 0x04),
|
|
40
|
+
password_disabled_alert=lockouts_live and bool(lockouts & 0x01),
|
|
41
|
+
nfc_disabled_alert=lockouts_live and bool(lockouts & 0x02),
|
|
42
|
+
finger_disabled_alert=lockouts_live and bool(lockouts & 0x04),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_bleak_advertisement(advertisement_data: object) -> Advertisement:
|
|
47
|
+
"""Decode straight from a bleak AdvertisementData.
|
|
48
|
+
|
|
49
|
+
Duck-typed, so importing this module never pulls bleak in.
|
|
50
|
+
"""
|
|
51
|
+
service_data = getattr(advertisement_data, "service_data", {}) or {}
|
|
52
|
+
manufacturer_data = getattr(advertisement_data, "manufacturer_data", {}) or {}
|
|
53
|
+
payload = service_data.get(SERVICE_UUID)
|
|
54
|
+
mfr = manufacturer_data.get(COMPANY_ID)
|
|
55
|
+
if payload is None or mfr is None:
|
|
56
|
+
raise ProtocolError("advertisement carries no SwitchBot service or manufacturer data")
|
|
57
|
+
return parse_advertisement(bytes(payload), bytes(mfr))
|
opensb/keypad/const.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Wire constants specific to the Keypad Touch."""
|
|
2
|
+
|
|
3
|
+
# service_data[0] & 0x7f. The keypad advertises 0x59 while in pairing mode.
|
|
4
|
+
DEVICE_TYPE = 0x79
|
|
5
|
+
DEVICE_TYPE_PAIRING = 0x59
|
|
6
|
+
|
|
7
|
+
# Command groups: 0x52 mutates, 0x53 reads.
|
|
8
|
+
GROUP_WRITE = 0x52
|
|
9
|
+
GROUP_READ = 0x53
|
|
10
|
+
|
|
11
|
+
# Which engine the sub-command addresses. Numbering is per category -- delete is 0x03
|
|
12
|
+
# for a fingerprint and 0x04 for a card -- so this byte cannot be swapped on its own.
|
|
13
|
+
CATEGORY_DEVICE = 0x01
|
|
14
|
+
CATEGORY_PASSWORD = 0x02
|
|
15
|
+
CATEGORY_NFC = 0x03
|
|
16
|
+
CATEGORY_FINGER = 0x04
|
|
17
|
+
|
|
18
|
+
# Asking for this slot makes the keypad allocate one and name it in the reply.
|
|
19
|
+
SLOT_AUTO = 0xFF
|
|
20
|
+
|
|
21
|
+
# Slots per credential kind: 90 ordinary + 10 urgent. There is no bulk read, so
|
|
22
|
+
# listing means walking this range.
|
|
23
|
+
SLOT_COUNT = 100
|
|
24
|
+
|
|
25
|
+
# Bytes of the set-password body carried per chunk.
|
|
26
|
+
PASSWORD_CHUNK = 11
|
|
27
|
+
|
|
28
|
+
# A log entry's credential id when the keypad could not place the credential.
|
|
29
|
+
LOG_UNKNOWN_CREDENTIAL = 255
|
|
30
|
+
|
|
31
|
+
# The passcode id the keypad reserves for its own test code.
|
|
32
|
+
LOG_TEST_PASSWORD = 10
|
opensb/keypad/device.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""The keypad as an object: connect, then ask it things."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
7
|
+
|
|
8
|
+
from opensb.ble.errors import ProtocolError
|
|
9
|
+
from opensb.ble.models import CommunicationKey, Window
|
|
10
|
+
from opensb.ble.session import Session
|
|
11
|
+
from opensb.ble.transport import BleakTransport, Transport
|
|
12
|
+
from opensb.keypad import frames, replies
|
|
13
|
+
from opensb.keypad.const import SLOT_AUTO, SLOT_COUNT
|
|
14
|
+
from opensb.keypad.enums import (
|
|
15
|
+
Backlight,
|
|
16
|
+
CredentialKind,
|
|
17
|
+
CredentialStatus,
|
|
18
|
+
FingerType,
|
|
19
|
+
LockButton,
|
|
20
|
+
NfcType,
|
|
21
|
+
PasswordType,
|
|
22
|
+
Switch,
|
|
23
|
+
)
|
|
24
|
+
from opensb.keypad.errors import EnrolmentError
|
|
25
|
+
from opensb.keypad.models import (
|
|
26
|
+
DeviceInfo,
|
|
27
|
+
Enrolment,
|
|
28
|
+
ExecutionStatus,
|
|
29
|
+
LogEntry,
|
|
30
|
+
StoredCard,
|
|
31
|
+
StoredPassword,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Credential kinds the keypad expects a validity window for.
|
|
35
|
+
TIMED_PASSWORD_TYPES = frozenset({PasswordType.TIME_LIMIT, PasswordType.ONCE})
|
|
36
|
+
|
|
37
|
+
# How long to let the user present a card, polled once a second.
|
|
38
|
+
CARD_WAIT_SECONDS = 30
|
|
39
|
+
|
|
40
|
+
# How long to leave the keypad alone while it reads a finger. It wants four presses.
|
|
41
|
+
FINGER_COLLECT_SECONDS = 40
|
|
42
|
+
|
|
43
|
+
ReadyCallback = Callable[[int], None] | Callable[[int], Awaitable[None]]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def _call(callback: ReadyCallback | None, slot: int) -> None:
|
|
47
|
+
if callback is None:
|
|
48
|
+
return
|
|
49
|
+
result = callback(slot)
|
|
50
|
+
if inspect.isawaitable(result):
|
|
51
|
+
await result
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Keypad:
|
|
55
|
+
"""A SwitchBot Keypad Touch, over its encrypted local channel."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, transport: Transport, key: CommunicationKey) -> None:
|
|
58
|
+
self.transport = transport
|
|
59
|
+
self.key = key
|
|
60
|
+
self.session = Session(transport, key)
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def over_ble(
|
|
64
|
+
cls,
|
|
65
|
+
key: CommunicationKey,
|
|
66
|
+
*,
|
|
67
|
+
adapter: str | None = None,
|
|
68
|
+
scan_timeout: float | None = None,
|
|
69
|
+
) -> Keypad:
|
|
70
|
+
"""A keypad reached over BLE at the address its key names."""
|
|
71
|
+
kwargs = {"scan_timeout": scan_timeout} if scan_timeout is not None else {}
|
|
72
|
+
return cls(BleakTransport(key.mac, adapter=adapter, **kwargs), key)
|
|
73
|
+
|
|
74
|
+
async def __aenter__(self) -> Keypad:
|
|
75
|
+
await self.connect()
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
async def __aexit__(self, *_exc: object) -> None:
|
|
79
|
+
await self.disconnect()
|
|
80
|
+
|
|
81
|
+
async def connect(self) -> None:
|
|
82
|
+
await self.transport.connect()
|
|
83
|
+
self.session.invalidate()
|
|
84
|
+
|
|
85
|
+
async def disconnect(self) -> None:
|
|
86
|
+
self.session.invalidate()
|
|
87
|
+
await self.transport.disconnect()
|
|
88
|
+
|
|
89
|
+
# --- the device itself ---
|
|
90
|
+
|
|
91
|
+
async def info(self) -> DeviceInfo:
|
|
92
|
+
"""Battery, versions and every setting, in one read."""
|
|
93
|
+
return replies.parse_device_info(await self.session.send(frames.device_info()))
|
|
94
|
+
|
|
95
|
+
async def status(self) -> ExecutionStatus:
|
|
96
|
+
"""What each credential engine is doing right now."""
|
|
97
|
+
return replies.parse_execution_status(await self.session.send(frames.execution_status()))
|
|
98
|
+
|
|
99
|
+
async def quick_unlock(self) -> bool:
|
|
100
|
+
"""Whether a code alone opens the lock, without the confirm key."""
|
|
101
|
+
return replies.parse_quick_unlock(await self.session.send(frames.get_quick_unlock()))
|
|
102
|
+
|
|
103
|
+
async def set_quick_unlock(self, enabled: bool) -> None:
|
|
104
|
+
await self.session.send(frames.set_quick_unlock(enabled))
|
|
105
|
+
|
|
106
|
+
async def set_keyboard_disabled(self, disabled: bool) -> None:
|
|
107
|
+
"""Turn the whole keypad off, so no credential opens the lock."""
|
|
108
|
+
await self.session.send(frames.set_keyboard_disabled(disabled))
|
|
109
|
+
|
|
110
|
+
async def set_removal_alarm(self, enabled: bool) -> None:
|
|
111
|
+
await self.session.send(frames.set_removal_alarm(enabled))
|
|
112
|
+
|
|
113
|
+
async def silence_removal_alarm(self) -> None:
|
|
114
|
+
"""Stop a sounding tamper alarm, leaving it armed."""
|
|
115
|
+
await self.session.send(frames.silence_removal_alarm())
|
|
116
|
+
|
|
117
|
+
async def set_backlight(self, mode: Backlight, level: int = 0) -> None:
|
|
118
|
+
await self.session.send(frames.set_light_and_sound(backlight=mode, level=level))
|
|
119
|
+
|
|
120
|
+
async def set_sound(self, enabled: bool) -> None:
|
|
121
|
+
await self.session.send(
|
|
122
|
+
frames.set_light_and_sound(sound=Switch.ON if enabled else Switch.OFF)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
async def set_lock_button(self, state: LockButton, seconds: int = 0) -> None:
|
|
126
|
+
await self.session.send(frames.set_lock_button(state, seconds))
|
|
127
|
+
|
|
128
|
+
async def clear_time_penalty(self, kind: CredentialKind) -> None:
|
|
129
|
+
"""Unblock an engine locked out after too many failed attempts."""
|
|
130
|
+
await self.session.send(frames.clear_time_penalty(kind))
|
|
131
|
+
|
|
132
|
+
async def clear_urgent_alarm(self, kind: CredentialKind) -> None:
|
|
133
|
+
"""Acknowledge the alarm a duress credential raised."""
|
|
134
|
+
await self.session.send(frames.clear_urgent_alarm(kind))
|
|
135
|
+
|
|
136
|
+
# --- passcodes ---
|
|
137
|
+
|
|
138
|
+
async def add_passcode(
|
|
139
|
+
self,
|
|
140
|
+
code: str,
|
|
141
|
+
password_type: PasswordType = PasswordType.PERMANENT,
|
|
142
|
+
*,
|
|
143
|
+
window: Window | None = None,
|
|
144
|
+
slot: int = SLOT_AUTO,
|
|
145
|
+
) -> int:
|
|
146
|
+
"""Store a passcode and return the slot it went into.
|
|
147
|
+
|
|
148
|
+
A TIME_LIMIT or ONCE code needs a window, written straight after the code.
|
|
149
|
+
"""
|
|
150
|
+
if password_type in TIMED_PASSWORD_TYPES and window is None:
|
|
151
|
+
raise ProtocolError(f"a {password_type.name} passcode needs a validity window")
|
|
152
|
+
reply = await self.session.send_chunked(frames.set_password(code, password_type, slot))
|
|
153
|
+
assigned = replies.parse_set_password(reply)
|
|
154
|
+
if window is not None:
|
|
155
|
+
await self.session.send(
|
|
156
|
+
frames.set_password_validity(assigned, window.starts_at, window.ends_at)
|
|
157
|
+
)
|
|
158
|
+
return assigned
|
|
159
|
+
|
|
160
|
+
async def read_passcode(self, slot: int) -> StoredPassword | None:
|
|
161
|
+
"""The passcode stored in `slot`, or None if it is empty."""
|
|
162
|
+
return replies.parse_stored_password(
|
|
163
|
+
await self.session.send(frames.read_password(slot)), slot
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
async def list_passcodes(self, slots: int = SLOT_COUNT) -> AsyncIterator[StoredPassword]:
|
|
167
|
+
"""Walk the slots and yield every passcode found.
|
|
168
|
+
|
|
169
|
+
There is no bulk read, so a full sweep of the 100 slots takes about a minute.
|
|
170
|
+
"""
|
|
171
|
+
for slot in range(slots):
|
|
172
|
+
stored = await self.read_passcode(slot)
|
|
173
|
+
if stored is not None:
|
|
174
|
+
yield stored
|
|
175
|
+
|
|
176
|
+
async def delete_passcode(self, slot: int) -> None:
|
|
177
|
+
await self.session.send(frames.delete_password(slot))
|
|
178
|
+
|
|
179
|
+
# --- NFC cards ---
|
|
180
|
+
|
|
181
|
+
async def read_card(self, slot: int) -> StoredCard | None:
|
|
182
|
+
"""The card stored in `slot`, reassembled from its packets, or None."""
|
|
183
|
+
if not replies.parse_nfc_exists(await self.session.send(frames.nfc_exists(slot))):
|
|
184
|
+
return None
|
|
185
|
+
data, packet = b"", 0
|
|
186
|
+
while True:
|
|
187
|
+
page = replies.parse_nfc_packet(
|
|
188
|
+
await self.session.send(frames.read_nfc_data(slot, packet))
|
|
189
|
+
)
|
|
190
|
+
data += page.payload
|
|
191
|
+
if page.is_last:
|
|
192
|
+
return StoredCard(slot=slot, data=data)
|
|
193
|
+
packet += 1
|
|
194
|
+
|
|
195
|
+
async def list_cards(self, slots: int = SLOT_COUNT) -> AsyncIterator[StoredCard]:
|
|
196
|
+
"""Walk the slots and yield every card found."""
|
|
197
|
+
for slot in range(slots):
|
|
198
|
+
card = await self.read_card(slot)
|
|
199
|
+
if card is not None:
|
|
200
|
+
yield card
|
|
201
|
+
|
|
202
|
+
async def delete_card(self, slot: int) -> None:
|
|
203
|
+
await self.session.send(frames.delete_nfc(slot))
|
|
204
|
+
|
|
205
|
+
async def enrol_card(
|
|
206
|
+
self,
|
|
207
|
+
nfc_type: NfcType = NfcType.PERMANENT,
|
|
208
|
+
*,
|
|
209
|
+
window: Window | None = None,
|
|
210
|
+
force: bool = False,
|
|
211
|
+
wait_seconds: int = CARD_WAIT_SECONDS,
|
|
212
|
+
on_ready: ReadyCallback | None = None,
|
|
213
|
+
) -> Enrolment:
|
|
214
|
+
"""Reserve a slot, wait for a card to be presented, and commit it.
|
|
215
|
+
|
|
216
|
+
`on_ready` is called with the reserved slot once the reader is armed.
|
|
217
|
+
|
|
218
|
+
`force=True` makes the keypad write its own data over the card, destroying
|
|
219
|
+
what it held -- never point it at a transit pass or bank card.
|
|
220
|
+
|
|
221
|
+
A failed enrolment leaves an all-zero stub in its reserved slot, so the slot
|
|
222
|
+
is released here before the error is raised.
|
|
223
|
+
"""
|
|
224
|
+
if nfc_type is NfcType.TIME_LIMIT and window is None:
|
|
225
|
+
raise ProtocolError("a TIME_LIMIT card needs a validity window")
|
|
226
|
+
stale = (await self.status()).nfc
|
|
227
|
+
slot = replies.parse_start_nfc_enrolment(
|
|
228
|
+
await self.session.send(frames.start_nfc_enrolment(nfc_type, force))
|
|
229
|
+
)
|
|
230
|
+
await _call(on_ready, slot)
|
|
231
|
+
outcome = await self._await_card(stale, wait_seconds)
|
|
232
|
+
if outcome is not CredentialStatus.SUCCESS:
|
|
233
|
+
await self.delete_card(slot)
|
|
234
|
+
raise EnrolmentError(outcome, f"card enrolment ended as {outcome.name}")
|
|
235
|
+
if window is not None:
|
|
236
|
+
await self.session.send(frames.set_nfc_validity(slot, window.starts_at, window.ends_at))
|
|
237
|
+
replies.parse_confirm_nfc(await self.session.send(frames.confirm_nfc(slot)))
|
|
238
|
+
return Enrolment(slot=slot, credential_type=nfc_type)
|
|
239
|
+
|
|
240
|
+
async def _await_card(self, stale: CredentialStatus, wait_seconds: int) -> CredentialStatus:
|
|
241
|
+
"""Poll until the card engine reports this attempt's verdict.
|
|
242
|
+
|
|
243
|
+
A status that was already non-idle is waited out rather than read as an
|
|
244
|
+
answer: the engine keeps reporting the previous attempt's result for a while,
|
|
245
|
+
and taking that at face value announces a verdict for a card nobody presented.
|
|
246
|
+
"""
|
|
247
|
+
settling = stale is not CredentialStatus.IDLE
|
|
248
|
+
for _ in range(wait_seconds):
|
|
249
|
+
current = (await self.status()).nfc
|
|
250
|
+
if settling:
|
|
251
|
+
settling = current is not CredentialStatus.IDLE
|
|
252
|
+
elif current is not CredentialStatus.IDLE:
|
|
253
|
+
return current
|
|
254
|
+
await asyncio.sleep(1)
|
|
255
|
+
if settling:
|
|
256
|
+
raise EnrolmentError(
|
|
257
|
+
stale,
|
|
258
|
+
f"the keypad is still reporting {stale.name} from an earlier attempt and "
|
|
259
|
+
"never went idle; power-cycle it or run one enrolment from the app",
|
|
260
|
+
)
|
|
261
|
+
return CredentialStatus.TIMEOUT
|
|
262
|
+
|
|
263
|
+
# --- fingerprints ---
|
|
264
|
+
|
|
265
|
+
async def delete_fingerprint(self, slot: int) -> None:
|
|
266
|
+
"""Erase a fingerprint slot.
|
|
267
|
+
|
|
268
|
+
Acknowledged whether or not a print was there, and unverifiable afterwards:
|
|
269
|
+
the slot query is an echo.
|
|
270
|
+
"""
|
|
271
|
+
await self.session.send(frames.delete_finger(slot))
|
|
272
|
+
|
|
273
|
+
async def enrol_fingerprint(
|
|
274
|
+
self,
|
|
275
|
+
finger_type: FingerType = FingerType.PERMANENT,
|
|
276
|
+
*,
|
|
277
|
+
window: Window | None = None,
|
|
278
|
+
enhance: bool = False,
|
|
279
|
+
collect_seconds: int = FINGER_COLLECT_SECONDS,
|
|
280
|
+
on_ready: ReadyCallback | None = None,
|
|
281
|
+
) -> Enrolment:
|
|
282
|
+
"""Reserve a slot, let the reader collect presses, and commit the print.
|
|
283
|
+
|
|
284
|
+
The link is dropped for `collect_seconds` and retaken for the verdict. While a
|
|
285
|
+
client holds it the keypad stays in verification mode, matching each press
|
|
286
|
+
against the stored prints instead of collecting them; enough of those trip the
|
|
287
|
+
lockout that `clear_time_penalty` clears.
|
|
288
|
+
|
|
289
|
+
`on_ready` is called with the reserved slot once the link is down.
|
|
290
|
+
|
|
291
|
+
`enhance=True` takes a second slot for a finger already enrolled. Both open
|
|
292
|
+
the lock, so deleting that finger means deleting both, and nothing on the
|
|
293
|
+
keypad pairs them -- the caller must.
|
|
294
|
+
"""
|
|
295
|
+
if finger_type is FingerType.TIME_LIMIT and window is None:
|
|
296
|
+
raise ProtocolError("a TIME_LIMIT fingerprint needs a validity window")
|
|
297
|
+
|
|
298
|
+
current = (await self.status()).fingerprint
|
|
299
|
+
if current is not CredentialStatus.IDLE:
|
|
300
|
+
await self.session.send(frames.cancel_finger_enrolment())
|
|
301
|
+
slot = replies.parse_start_finger_enrolment(
|
|
302
|
+
await self.session.send(frames.start_finger_enrolment(finger_type, enhance))
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
await self.disconnect()
|
|
306
|
+
await _call(on_ready, slot)
|
|
307
|
+
await asyncio.sleep(collect_seconds)
|
|
308
|
+
await self.connect()
|
|
309
|
+
|
|
310
|
+
outcome = await self.status()
|
|
311
|
+
if outcome.fingerprint is not CredentialStatus.SUCCESS:
|
|
312
|
+
await self.session.send(frames.cancel_finger_enrolment())
|
|
313
|
+
raise EnrolmentError(
|
|
314
|
+
outcome.fingerprint,
|
|
315
|
+
f"fingerprint enrolment ended as {outcome.fingerprint.name} after "
|
|
316
|
+
f"{outcome.fingerprint_step} press(es)",
|
|
317
|
+
)
|
|
318
|
+
if window is not None:
|
|
319
|
+
await self.session.send(
|
|
320
|
+
frames.set_finger_validity(slot, window.starts_at, window.ends_at)
|
|
321
|
+
)
|
|
322
|
+
replies.parse_confirm_finger(await self.session.send(frames.confirm_finger(slot)))
|
|
323
|
+
return Enrolment(
|
|
324
|
+
slot=slot, credential_type=finger_type, presses=outcome.fingerprint_step + 1
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# --- event log ---
|
|
328
|
+
|
|
329
|
+
async def read_log(self, since: int | None = None, limit: int = 50) -> AsyncIterator[LogEntry]:
|
|
330
|
+
"""Walk the keypad's own log, newest first, back from `since`."""
|
|
331
|
+
await self.session.send(frames.set_log_cursor(int(time.time()) if since is None else since))
|
|
332
|
+
for _ in range(limit):
|
|
333
|
+
entry = replies.parse_log_entry(await self.session.send(frames.read_log()))
|
|
334
|
+
if entry is None:
|
|
335
|
+
return
|
|
336
|
+
yield entry
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Finding Keypad Touch devices among whatever is on the air."""
|
|
2
|
+
|
|
3
|
+
from opensb.ble.discovery import DEFAULT_DISCOVERY_SECONDS, scan
|
|
4
|
+
from opensb.ble.errors import ProtocolError
|
|
5
|
+
from opensb.ble.models import Frozen
|
|
6
|
+
from opensb.keypad.advertisement import parse_bleak_advertisement
|
|
7
|
+
from opensb.keypad.models import Advertisement
|
|
8
|
+
from pydantic import Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DiscoveredKeypad(Frozen):
|
|
12
|
+
"""A keypad seen on the air."""
|
|
13
|
+
|
|
14
|
+
address: str = Field(description="BLE address to connect to")
|
|
15
|
+
name: str | None = Field(default=None, description="Advertised local name")
|
|
16
|
+
rssi: int = Field(description="Signal strength of the last advertisement seen")
|
|
17
|
+
state: Advertisement = Field(description="What that advertisement said")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def discover(seconds: float = DEFAULT_DISCOVERY_SECONDS) -> list[DiscoveredKeypad]:
|
|
21
|
+
"""Scan for Keypad Touch devices in range.
|
|
22
|
+
|
|
23
|
+
The keypad advertises only in bursts, so a unit that has been idle may need a
|
|
24
|
+
keypress before it shows up.
|
|
25
|
+
"""
|
|
26
|
+
keypads = []
|
|
27
|
+
for seen in await scan(seconds):
|
|
28
|
+
try:
|
|
29
|
+
state = parse_bleak_advertisement(seen.advertisement)
|
|
30
|
+
except ProtocolError:
|
|
31
|
+
continue # anything that is not a keypad
|
|
32
|
+
keypads.append(
|
|
33
|
+
DiscoveredKeypad(address=seen.address, name=seen.name, rssi=seen.rssi, state=state)
|
|
34
|
+
)
|
|
35
|
+
return keypads
|
opensb/keypad/enums.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Enumerations specific to the Keypad Touch."""
|
|
2
|
+
|
|
3
|
+
from enum import IntEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PasswordType(IntEnum):
|
|
7
|
+
"""Kind of passcode stored in a slot.
|
|
8
|
+
|
|
9
|
+
TIME_LIMIT and ONCE need a validity window written after the code itself.
|
|
10
|
+
URGENT is a duress code: it opens the lock and raises the urgent-password alarm.
|
|
11
|
+
INTERVAL and TEST are catalogued but their firmware behaviour is unknown.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
PERMANENT = 0
|
|
15
|
+
TIME_LIMIT = 1
|
|
16
|
+
ONCE = 2
|
|
17
|
+
URGENT = 3
|
|
18
|
+
INTERVAL = 4
|
|
19
|
+
TEST = 5
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NfcType(IntEnum):
|
|
23
|
+
"""Kind of NFC card credential.
|
|
24
|
+
|
|
25
|
+
Its own scale: URGENT is 2 here and 3 for a passcode, so the two are not
|
|
26
|
+
interchangeable.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
PERMANENT = 0
|
|
30
|
+
TIME_LIMIT = 1
|
|
31
|
+
URGENT = 2
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FingerType(IntEnum):
|
|
35
|
+
"""Kind of fingerprint credential; shares the card scale, not the passcode one."""
|
|
36
|
+
|
|
37
|
+
PERMANENT = 0
|
|
38
|
+
TIME_LIMIT = 1
|
|
39
|
+
URGENT = 2
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CredentialStatus(IntEnum):
|
|
43
|
+
"""Progress of an in-flight enrolment, from the execution-status poll."""
|
|
44
|
+
|
|
45
|
+
IDLE = 0
|
|
46
|
+
SUCCESS = 1
|
|
47
|
+
ERROR = 2
|
|
48
|
+
ALREADY_EXISTS = 3
|
|
49
|
+
TIMEOUT = 4
|
|
50
|
+
UNSUPPORTED_CARD = 5
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CredentialKind(IntEnum):
|
|
54
|
+
"""Which engine an alarm or a lockout belongs to."""
|
|
55
|
+
|
|
56
|
+
PASSWORD = 1
|
|
57
|
+
NFC = 2
|
|
58
|
+
FINGERPRINT = 4
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class LockButton(IntEnum):
|
|
62
|
+
"""What the keypad's lock button does. TIMED carries a window in seconds."""
|
|
63
|
+
|
|
64
|
+
DISABLED = 1
|
|
65
|
+
ENABLED = 2
|
|
66
|
+
TIMED = 3
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Backlight(IntEnum):
|
|
70
|
+
"""Backlight mode."""
|
|
71
|
+
|
|
72
|
+
OFF = 1
|
|
73
|
+
AUTO = 2
|
|
74
|
+
ALWAYS_ON = 3
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Switch(IntEnum):
|
|
78
|
+
"""A two-valued setting, plus a KEEP that leaves the field untouched."""
|
|
79
|
+
|
|
80
|
+
KEEP = 0
|
|
81
|
+
OFF = 1
|
|
82
|
+
ON = 2
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class LogSource(IntEnum):
|
|
86
|
+
"""What raised a log entry.
|
|
87
|
+
|
|
88
|
+
The keypad's own axis, which does not match the lock's. FACE and DOORBELL belong
|
|
89
|
+
to siblings of this device.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
PASSWORD = 1
|
|
93
|
+
NFC = 2
|
|
94
|
+
FINGERPRINT = 3
|
|
95
|
+
LOCK = 4
|
|
96
|
+
ALARM = 5
|
|
97
|
+
FACE = 6
|
|
98
|
+
DOORBELL = 7
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# Sources that report a credential being presented, and so carry a credential id.
|
|
102
|
+
CREDENTIAL_SOURCES = frozenset(
|
|
103
|
+
{LogSource.PASSWORD, LogSource.NFC, LogSource.FINGERPRINT, LogSource.FACE}
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class UnlockAction(IntEnum):
|
|
108
|
+
"""Outcome of a credential being presented, for a CREDENTIAL_SOURCES entry."""
|
|
109
|
+
|
|
110
|
+
SUCCESS = 0
|
|
111
|
+
ERROR = 1
|
|
112
|
+
NOT_RECOGNISED = 2
|
|
113
|
+
EXPIRED = 3
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class LockAction(IntEnum):
|
|
117
|
+
"""Outcome of the keypad's own lock button, for a LogSource.LOCK entry."""
|
|
118
|
+
|
|
119
|
+
SUCCESS = 0
|
|
120
|
+
ERROR = 1
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class AlarmAction(IntEnum):
|
|
124
|
+
"""What a LogSource.ALARM entry is reporting."""
|
|
125
|
+
|
|
126
|
+
LOW_BATTERY = 0
|
|
127
|
+
LOCKOUT = 1
|
|
128
|
+
REMOVED = 2
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class LockoutKind(IntEnum):
|
|
132
|
+
"""Which engine was locked out, carried in an AlarmAction.LOCKOUT entry's value.
|
|
133
|
+
|
|
134
|
+
Numbered 1-3, unlike CredentialKind's bitmask.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
PASSWORD = 1
|
|
138
|
+
NFC = 2
|
|
139
|
+
FINGERPRINT = 3
|
opensb/keypad/errors.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Exceptions specific to the Keypad Touch."""
|
|
2
|
+
|
|
3
|
+
from opensb.ble.errors import OpenSBError
|
|
4
|
+
from opensb.keypad.enums import CredentialStatus
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class EnrolmentError(OpenSBError):
|
|
8
|
+
"""An interactive enrolment did not produce a credential."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, status: CredentialStatus, message: str = "") -> None:
|
|
11
|
+
self.status = status
|
|
12
|
+
super().__init__(message or f"enrolment ended as {status.name}")
|