opensb-keypad 0.0.1__tar.gz
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-0.0.1/.gitignore +27 -0
- opensb_keypad-0.0.1/LICENSE +21 -0
- opensb_keypad-0.0.1/PKG-INFO +23 -0
- opensb_keypad-0.0.1/README.md +6 -0
- opensb_keypad-0.0.1/pyproject.toml +29 -0
- opensb_keypad-0.0.1/src/opensb/keypad/__init__.py +65 -0
- opensb_keypad-0.0.1/src/opensb/keypad/advertisement.py +57 -0
- opensb_keypad-0.0.1/src/opensb/keypad/const.py +32 -0
- opensb_keypad-0.0.1/src/opensb/keypad/device.py +336 -0
- opensb_keypad-0.0.1/src/opensb/keypad/discovery.py +35 -0
- opensb_keypad-0.0.1/src/opensb/keypad/enums.py +139 -0
- opensb_keypad-0.0.1/src/opensb/keypad/errors.py +12 -0
- opensb_keypad-0.0.1/src/opensb/keypad/frames.py +260 -0
- opensb_keypad-0.0.1/src/opensb/keypad/models.py +284 -0
- opensb_keypad-0.0.1/src/opensb/keypad/py.typed +0 -0
- opensb_keypad-0.0.1/src/opensb/keypad/replies.py +155 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Python-generated files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[oc]
|
|
4
|
+
build/
|
|
5
|
+
dist/
|
|
6
|
+
wheels/
|
|
7
|
+
*.egg-info
|
|
8
|
+
|
|
9
|
+
# Virtual environments
|
|
10
|
+
.venv
|
|
11
|
+
venv
|
|
12
|
+
|
|
13
|
+
# Tooling caches
|
|
14
|
+
.coverage
|
|
15
|
+
coverage.xml
|
|
16
|
+
htmlcov/
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.pyrefly_cache/
|
|
19
|
+
.ruff_cache/
|
|
20
|
+
|
|
21
|
+
# Never commit a communication key: it reads every stored passcode back out.
|
|
22
|
+
# Broad on purpose -- the fetch tools name these keypad_key.json, lock_key.json, keypad.json.
|
|
23
|
+
*key*.json
|
|
24
|
+
*.pem
|
|
25
|
+
.env
|
|
26
|
+
*.env
|
|
27
|
+
secrets/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yorsh Siarhei
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: opensb-keypad
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Local BLE control of the SwitchBot Keypad Touch: passcodes, cards and fingerprints
|
|
5
|
+
Author-email: Yorsh Siarhei <yorsh.srg@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: ble,bluetooth,home-assistant,keypad,switchbot
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
12
|
+
Classifier: Topic :: Home Automation
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.14
|
|
15
|
+
Requires-Dist: opensb-core
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# opensb-keypad
|
|
19
|
+
|
|
20
|
+
Local BLE control of the SwitchBot Keypad Touch: passcodes, NFC cards and
|
|
21
|
+
fingerprints, with no cloud after a one-time key fetch.
|
|
22
|
+
|
|
23
|
+
python3 -m pip install 'opensb[keypad]'
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "opensb-keypad"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Local BLE control of the SwitchBot Keypad Touch: passcodes, cards and fingerprints"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "Yorsh Siarhei", email = "yorsh.srg@gmail.com" }]
|
|
10
|
+
keywords = ["switchbot", "keypad", "ble", "bluetooth", "home-assistant"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Programming Language :: Python :: 3.14",
|
|
15
|
+
"Topic :: Home Automation",
|
|
16
|
+
"Typing :: Typed",
|
|
17
|
+
]
|
|
18
|
+
dependencies = ["opensb-core"]
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
22
|
+
build-backend = "hatchling.build"
|
|
23
|
+
|
|
24
|
+
[tool.hatch.version]
|
|
25
|
+
source = "vcs"
|
|
26
|
+
raw-options = { root = "../..", fallback_version = "0.0.0" }
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["src/opensb"]
|
|
@@ -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))
|
|
@@ -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
|