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.
@@ -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
@@ -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
@@ -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}")