opensb-lockpro 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.
@@ -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-lockpro
3
+ Version: 0.0.1
4
+ Summary: Local BLE control of the SwitchBot Lock Pro: lock, unlock, status and the event log
5
+ Author-email: Yorsh Siarhei <yorsh.srg@gmail.com>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: ble,bluetooth,home-assistant,lock,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-lockpro
19
+
20
+ Local BLE control of the SwitchBot Lock Pro: lock, unlock, status, settings, emergency
21
+ unlock, calibration and the event log.
22
+
23
+ python3 -m pip install 'opensb[lockpro]'
@@ -0,0 +1,6 @@
1
+ # opensb-lockpro
2
+
3
+ Local BLE control of the SwitchBot Lock Pro: lock, unlock, status, settings, emergency
4
+ unlock, calibration and the event log.
5
+
6
+ python3 -m pip install 'opensb[lockpro]'
@@ -0,0 +1,29 @@
1
+ [project]
2
+ name = "opensb-lockpro"
3
+ dynamic = ["version"]
4
+ description = "Local BLE control of the SwitchBot Lock Pro: lock, unlock, status and the event log"
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", "lock", "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,54 @@
1
+ """The SwitchBot Lock Pro: lock, unlock, settings, calibration and its event log."""
2
+
3
+ from opensb.lockpro.advertisement import (
4
+ is_lock,
5
+ parse_advertisement,
6
+ parse_bleak_advertisement,
7
+ status_from_byte,
8
+ )
9
+ from opensb.lockpro.device import Lock
10
+ from opensb.lockpro.discovery import DiscoveredLock, discover
11
+ from opensb.lockpro.enums import (
12
+ KeyAction,
13
+ KeyTrigger,
14
+ LatchType,
15
+ LockAlert,
16
+ LockStatus,
17
+ LogSource,
18
+ )
19
+ from opensb.lockpro.models import (
20
+ Advertisement,
21
+ Battery,
22
+ BatteryBay,
23
+ BatteryBays,
24
+ LockFlags,
25
+ LockInfo,
26
+ LogEntry,
27
+ Settings,
28
+ TimedSetting,
29
+ )
30
+
31
+ __all__ = [
32
+ "Advertisement",
33
+ "Battery",
34
+ "BatteryBay",
35
+ "BatteryBays",
36
+ "DiscoveredLock",
37
+ "KeyAction",
38
+ "KeyTrigger",
39
+ "LatchType",
40
+ "Lock",
41
+ "LockAlert",
42
+ "LockFlags",
43
+ "LockInfo",
44
+ "LockStatus",
45
+ "LogEntry",
46
+ "LogSource",
47
+ "Settings",
48
+ "TimedSetting",
49
+ "discover",
50
+ "is_lock",
51
+ "parse_advertisement",
52
+ "parse_bleak_advertisement",
53
+ "status_from_byte",
54
+ ]
@@ -0,0 +1,64 @@
1
+ """Decode what the lock 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.lockpro.const import DEVICE_TYPE, DEVICE_TYPE_PAIRING
6
+ from opensb.lockpro.enums import LockStatus
7
+ from opensb.lockpro.models import Advertisement
8
+
9
+ LOCK_DEVICE_TYPES = frozenset({DEVICE_TYPE, DEVICE_TYPE_PAIRING})
10
+
11
+
12
+ def is_lock(service_data: bytes) -> bool:
13
+ """Whether this 0xFD3D service data came from a Lock Pro."""
14
+ return len(service_data) >= 3 and (service_data[0] & 0x7F) in LOCK_DEVICE_TYPES
15
+
16
+
17
+ def status_from_byte(value: int) -> LockStatus:
18
+ """Decode the status byte the advertisement and the info reply share."""
19
+ return LockStatus((value & 0x78) >> 3)
20
+
21
+
22
+ def parse_advertisement(service_data: bytes, manufacturer_data: bytes) -> Advertisement:
23
+ """Decode a Lock Pro advertisement.
24
+
25
+ Only the primary cell's percentage is broadcast; the second is reported as a
26
+ low-battery flag and nothing more.
27
+ """
28
+ if not is_lock(service_data):
29
+ raise ProtocolError(f"not Lock Pro service data: {service_data.hex()}")
30
+ if len(manufacturer_data) < 12:
31
+ raise ProtocolError(f"lock manufacturer data too short: {manufacturer_data.hex()}")
32
+
33
+ state, autolock, charge, group, alerts = manufacturer_data[7:12]
34
+ return Advertisement(
35
+ status=status_from_byte(state),
36
+ calibrated=bool(state & 0x80),
37
+ action_source=state & 0x07,
38
+ battery=service_data[2] & 0x7F,
39
+ auto_lock_paused=bool(autolock & 0x08),
40
+ charging=bool(charge & 0x80),
41
+ grouped=bool(group & 0x80),
42
+ door_open_alert=bool(alerts & 0x80),
43
+ unlocked_alert=bool(alerts & 0x40),
44
+ low_temp_alert=bool(alerts & 0x20),
45
+ weak_drive_alert=bool(alerts & 0x10),
46
+ door_magnet_alert=bool(alerts & 0x08),
47
+ left_battery_low=bool(alerts & 0x04),
48
+ right_battery_low=bool(alerts & 0x02),
49
+ sequence=manufacturer_data[6] & 0x3F,
50
+ )
51
+
52
+
53
+ def parse_bleak_advertisement(advertisement_data: object) -> Advertisement:
54
+ """Decode straight from a bleak AdvertisementData.
55
+
56
+ Duck-typed, so importing this module never pulls bleak in.
57
+ """
58
+ service_data = getattr(advertisement_data, "service_data", {}) or {}
59
+ manufacturer_data = getattr(advertisement_data, "manufacturer_data", {}) or {}
60
+ payload = service_data.get(SERVICE_UUID)
61
+ mfr = manufacturer_data.get(COMPANY_ID)
62
+ if payload is None or mfr is None:
63
+ raise ProtocolError("advertisement carries no SwitchBot service or manufacturer data")
64
+ return parse_advertisement(bytes(payload), bytes(mfr))
@@ -0,0 +1,9 @@
1
+ """Wire constants specific to the Lock Pro."""
2
+
3
+ # service_data[0] & 0x7f. 0x04 is the pair-mode marker.
4
+ DEVICE_TYPE = 0x24
5
+ DEVICE_TYPE_PAIRING = 0x04
6
+
7
+ # Control and info groups, the Lock Pro's analogue of the keypad's 0x52 / 0x53.
8
+ GROUP_CONTROL = 0x4E
9
+ GROUP_INFO = 0x4F
@@ -0,0 +1,230 @@
1
+ """The lock as an object: connect, then ask it things."""
2
+
3
+ import time
4
+ from collections.abc import AsyncIterator
5
+
6
+ from opensb.ble.models import CommunicationKey
7
+ from opensb.ble.session import Session
8
+ from opensb.ble.transport import BleakTransport, Transport
9
+ from opensb.lockpro import frames, replies
10
+ from opensb.lockpro.enums import KeyAction, KeyTrigger, LatchType, LockAlert
11
+ from opensb.lockpro.models import (
12
+ Battery,
13
+ BatteryBays,
14
+ LockFlags,
15
+ LockInfo,
16
+ LogEntry,
17
+ Settings,
18
+ TimedSetting,
19
+ )
20
+
21
+
22
+ class Lock:
23
+ """A SwitchBot Lock Pro, over its encrypted local channel."""
24
+
25
+ def __init__(self, transport: Transport, key: CommunicationKey) -> None:
26
+ self.transport = transport
27
+ self.key = key
28
+ self.session = Session(transport, key)
29
+
30
+ @classmethod
31
+ def over_ble(
32
+ cls,
33
+ key: CommunicationKey,
34
+ *,
35
+ adapter: str | None = None,
36
+ scan_timeout: float | None = None,
37
+ ) -> Lock:
38
+ """A lock reached over BLE at the address its key names."""
39
+ kwargs = {"scan_timeout": scan_timeout} if scan_timeout is not None else {}
40
+ return cls(BleakTransport(key.mac, adapter=adapter, **kwargs), key)
41
+
42
+ async def __aenter__(self) -> Lock:
43
+ await self.connect()
44
+ return self
45
+
46
+ async def __aexit__(self, *_exc: object) -> None:
47
+ await self.disconnect()
48
+
49
+ async def connect(self) -> None:
50
+ await self.transport.connect()
51
+ self.session.invalidate()
52
+
53
+ async def disconnect(self) -> None:
54
+ self.session.invalidate()
55
+ await self.transport.disconnect()
56
+
57
+ async def info(self) -> LockInfo:
58
+ """Where the bolt is, and the battery."""
59
+ return replies.parse_info(await self.session.send(frames.info()))
60
+
61
+ async def battery(self) -> Battery:
62
+ """Battery percentage and firmware version."""
63
+ return replies.parse_battery(await self.session.send(frames.battery()))
64
+
65
+ async def lock(self) -> LockInfo:
66
+ """Throw the bolt."""
67
+ return replies.parse_info(await self.session.send(frames.lock()))
68
+
69
+ async def unlock(self, unlatch: bool = True) -> LockInfo:
70
+ """Withdraw the bolt. `unlatch` also pulls the latch, which opens the door."""
71
+ return replies.parse_info(await self.session.send(frames.unlock(unlatch)))
72
+
73
+ async def emergency_unlock(self, reverse: bool = False) -> None:
74
+ """Drive the motor until it stalls, ignoring the calibrated end positions.
75
+
76
+ The only control that still works on a jammed lock. It clears the
77
+ calibration, so `calibrate` has to follow, and until it does the status the
78
+ lock reports means nothing -- which is why the app asks whether the door
79
+ actually opened, and offers `reverse` when it did not.
80
+ """
81
+ await self.session.send(frames.emergency_unlock(reverse))
82
+
83
+ # --- calibration ---
84
+ # Every step has to run inside one `async with`: over separate connections each
85
+ # is still accepted while nothing is recorded, and the commit then leaves the
86
+ # lock uncalibrated.
87
+
88
+ async def enter_calibration(self) -> None:
89
+ """Release the motor so the knob turns by hand, and start recording."""
90
+ await self.session.send(frames.enter_calibration())
91
+
92
+ async def set_lock_position(self) -> None:
93
+ """Record where the knob is now as locked, and the door as closed."""
94
+ await self.session.send(frames.set_lock_position())
95
+
96
+ async def set_unlock_position(self) -> None:
97
+ """Record where the knob is now as unlocked."""
98
+ await self.session.send(frames.set_unlock_position())
99
+
100
+ async def set_door_open_position(self) -> None:
101
+ """Record the door as open, for the door sensor."""
102
+ await self.session.send(frames.set_door_open_position())
103
+
104
+ async def test_unlock(self) -> None:
105
+ """Drive to the recorded unlock position, still inside calibration."""
106
+ await self.session.send(frames.test_unlock())
107
+
108
+ async def test_lock(self) -> None:
109
+ """Drive to the recorded lock position, still inside calibration."""
110
+ await self.session.send(frames.test_lock())
111
+
112
+ async def set_latch_type(self, latch: LatchType) -> None:
113
+ """Tell the lock which latch it drives, inside a calibration run.
114
+
115
+ The lock detects this itself, so the app only ever confirms what the lock
116
+ already reported. On the Lock Pro the app never sends it at all.
117
+ """
118
+ await self.session.send(frames.set_latch_type(latch))
119
+
120
+ async def finish_calibration(self) -> None:
121
+ """Commit the recorded positions and leave calibration mode."""
122
+ await self.session.send(frames.finish_calibration())
123
+
124
+ async def recover_calibration(self) -> None:
125
+ """Abandon a calibration run in progress.
126
+
127
+ Not a way back to a calibration that is already lost: an uncalibrated lock
128
+ accepts this and nothing changes.
129
+ """
130
+ await self.session.send(frames.recover_calibration())
131
+
132
+ # --- settings ---
133
+
134
+ async def flags(self) -> LockFlags:
135
+ """Light, sound, linkage and the lock's own button, all in one byte."""
136
+ return replies.parse_flags(await self.session.send(frames.read_flags()))
137
+
138
+ async def auto_lock(self) -> TimedSetting:
139
+ """How long after unlocking the lock closes itself."""
140
+ return replies.parse_timed_setting(
141
+ await self.session.send(frames.read_auto_lock()), "auto lock"
142
+ )
143
+
144
+ async def force_lock(self) -> TimedSetting:
145
+ """Whether it locks even with the door reported open, and after how long."""
146
+ return replies.parse_timed_setting(
147
+ await self.session.send(frames.read_force_lock()), "force lock"
148
+ )
149
+
150
+ async def auto_lock_paused(self) -> bool:
151
+ """Whether auto-lock is suspended, with its delay remembered."""
152
+ return replies.parse_auto_lock_paused(
153
+ await self.session.send(frames.read_auto_lock_paused())
154
+ )
155
+
156
+ async def latch_keep_time(self) -> int:
157
+ """Seconds the latch stays retracted before springing back."""
158
+ return replies.parse_latch_keep_time(
159
+ await self.session.send(frames.read_latch_keep_time())
160
+ )
161
+
162
+ async def battery_bays(self) -> BatteryBays:
163
+ """Charge in each of the two bays, and whether a cell sits in them."""
164
+ return replies.parse_battery_bays(await self.session.send(frames.read_battery_bays()))
165
+
166
+ async def alert(self, kind: LockAlert) -> TimedSetting:
167
+ """One alert's flag and delay."""
168
+ return replies.parse_timed_setting(
169
+ await self.session.send(frames.read_alert(kind)), f"alert {kind.name}"
170
+ )
171
+
172
+ async def settings(self) -> Settings:
173
+ """Every configuration value, read one at a time over the open connection."""
174
+ return Settings(
175
+ flags=await self.flags(),
176
+ auto_lock=await self.auto_lock(),
177
+ force_lock=await self.force_lock(),
178
+ auto_lock_paused=await self.auto_lock_paused(),
179
+ latch_keep_time=await self.latch_keep_time(),
180
+ door_left_open_alert=await self.alert(LockAlert.DOOR_LEFT_OPEN),
181
+ not_locked_alert=await self.alert(LockAlert.NOT_LOCKED),
182
+ battery_bays=await self.battery_bays(),
183
+ )
184
+
185
+ async def set_light(self, enabled: bool) -> None:
186
+ await self.session.send(frames.set_light(enabled))
187
+
188
+ async def set_sound(self, enabled: bool) -> None:
189
+ await self.session.send(frames.set_sound(enabled))
190
+
191
+ async def set_manual_unlock_linkage(self, enabled: bool) -> None:
192
+ """Report a by-hand unlock to whatever is linked to the lock."""
193
+ await self.session.send(frames.set_manual_unlock_linkage(enabled))
194
+
195
+ async def set_key(
196
+ self,
197
+ enabled: bool,
198
+ action: KeyAction = KeyAction.TOGGLE,
199
+ trigger: KeyTrigger = KeyTrigger.SINGLE,
200
+ ) -> None:
201
+ """What the lock's own button does. Switching it off keeps the rest."""
202
+ await self.session.send(frames.set_key(enabled, action, trigger))
203
+
204
+ async def set_auto_lock(self, enabled: bool, seconds: int = 0) -> None:
205
+ await self.session.send(frames.set_auto_lock(enabled, seconds))
206
+
207
+ async def set_force_lock(self, enabled: bool, seconds: int = 0) -> None:
208
+ await self.session.send(frames.set_force_lock(enabled, seconds))
209
+
210
+ async def set_auto_lock_paused(self, paused: bool) -> None:
211
+ await self.session.send(frames.set_auto_lock_paused(paused))
212
+
213
+ async def set_alert(self, kind: LockAlert, enabled: bool, seconds: int = 0) -> None:
214
+ await self.session.send(frames.set_alert(kind, enabled, seconds))
215
+
216
+ async def set_latch_keep_time(self, seconds: int) -> None:
217
+ await self.session.send(frames.set_latch_keep_time(seconds))
218
+
219
+ async def set_notify(self, enabled: bool = True) -> None:
220
+ """Ask the lock to report state changes as they happen."""
221
+ await self.session.send(frames.set_notify(enabled))
222
+
223
+ async def read_log(self, since: int | None = None, limit: int = 50) -> AsyncIterator[LogEntry]:
224
+ """Walk the lock's event log, newest first, back from `since`."""
225
+ await self.session.send(frames.set_log_cursor(int(time.time()) if since is None else since))
226
+ for _ in range(limit):
227
+ entry = replies.parse_log_entry(await self.session.send(frames.read_log()))
228
+ if entry is None:
229
+ return
230
+ yield entry
@@ -0,0 +1,31 @@
1
+ """Finding Lock Pro 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.lockpro.advertisement import parse_bleak_advertisement
7
+ from opensb.lockpro.models import Advertisement
8
+ from pydantic import Field
9
+
10
+
11
+ class DiscoveredLock(Frozen):
12
+ """A lock 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[DiscoveredLock]:
21
+ """Scan for Lock Pro devices in range."""
22
+ locks = []
23
+ for seen in await scan(seconds):
24
+ try:
25
+ state = parse_bleak_advertisement(seen.advertisement)
26
+ except ProtocolError:
27
+ continue # anything that is not a Lock Pro
28
+ locks.append(
29
+ DiscoveredLock(address=seen.address, name=seen.name, rssi=seen.rssi, state=state)
30
+ )
31
+ return locks
@@ -0,0 +1,65 @@
1
+ """Enumerations specific to the Lock Pro."""
2
+
3
+ from enum import IntEnum
4
+
5
+
6
+ class LockStatus(IntEnum):
7
+ """Where the bolt is, from bits 3-6 of the status byte."""
8
+
9
+ LOCKED = 0
10
+ UNLOCKED = 1
11
+ LOCKING = 2
12
+ UNLOCKING = 3
13
+ LOCKING_BLOCKED = 4
14
+ UNLOCKING_BLOCKED = 5
15
+ LATCH_LOCKED = 6
16
+ PARTIALLY_LOCKED = 7
17
+
18
+
19
+ class LogSource(IntEnum):
20
+ """What raised a lock-log entry.
21
+
22
+ A credential entry is the trigger -- a code entered on a paired keypad -- and the
23
+ actuation entries that follow it are the motor moving.
24
+ """
25
+
26
+ OTHER = -1
27
+ RESTART = 1
28
+ CREDENTIAL = 2
29
+ ACTUATION = 3
30
+ MANUAL = 6
31
+ SYSTEM = 14
32
+
33
+
34
+ class LockAlert(IntEnum):
35
+ """The lock's two alerts. Each has its own enable flag and delay."""
36
+
37
+ DOOR_LEFT_OPEN = 1
38
+ NOT_LOCKED = 2
39
+
40
+
41
+ class KeyAction(IntEnum):
42
+ """What a press of the lock's own button does."""
43
+
44
+ LOCK = 0
45
+ UNLOCK = 1
46
+ TOGGLE = 2
47
+
48
+
49
+ class KeyTrigger(IntEnum):
50
+ """How many presses the button wants."""
51
+
52
+ SINGLE = 0
53
+ DOUBLE = 1
54
+
55
+
56
+ class LatchType(IntEnum):
57
+ """What kind of latch the lock drives.
58
+
59
+ UNKNOWN is what the lock reports before it has decided; only the other two are
60
+ ever written.
61
+ """
62
+
63
+ UNKNOWN = 0
64
+ NORMAL = 1
65
+ NIGHT_LATCH = 2
@@ -0,0 +1,249 @@
1
+ """Plaintext Lock Pro command frames, before the encryption envelope.
2
+
3
+ The Lock Pro's bytes differ from the base Lock's for the same actions.
4
+ """
5
+
6
+ from opensb.ble.const import FRAME_MAGIC
7
+ from opensb.ble.errors import ProtocolError
8
+ from opensb.lockpro.enums import KeyAction, KeyTrigger, LatchType, LockAlert
9
+
10
+ # Byte 5 of a control or settings frame: one lock on its own, not half of a pair.
11
+ SINGLE = 0x00
12
+
13
+
14
+ def lock() -> bytes:
15
+ """Throw the bolt."""
16
+ return bytes.fromhex("570f4e0101000000")
17
+
18
+
19
+ def unlock(unlatch: bool = True) -> bytes:
20
+ """Withdraw the bolt. `unlatch` also pulls the latch, which opens the door."""
21
+ return bytes.fromhex("570f4e0101000080" if unlatch else "570f4e01010000a0")
22
+
23
+
24
+ def info() -> bytes:
25
+ """Read status and battery."""
26
+ return bytes.fromhex("570f4f8104")
27
+
28
+
29
+ def battery() -> bytes:
30
+ """Read battery percentage and firmware version."""
31
+ return bytes([FRAME_MAGIC, 0x02])
32
+
33
+
34
+ def set_notify(enabled: bool = True) -> bytes:
35
+ """Ask the lock to report state changes on the notify characteristic."""
36
+ return bytes.fromhex("570e01001e00008104" if enabled else "570e00")
37
+
38
+
39
+ def set_log_cursor(unix_seconds: int) -> bytes:
40
+ """Point the log cursor: subsequent reads return entries older than this."""
41
+ return bytes([FRAME_MAGIC, 0x00, 0x14, 0x01]) + (unix_seconds & 0xFFFFFFFF).to_bytes(4, "big")
42
+
43
+
44
+ def read_log() -> bytes:
45
+ """Read the next batch of entries older than the cursor."""
46
+ return bytes.fromhex("57001405")
47
+
48
+
49
+ def emergency_unlock(reverse: bool = False) -> bytes:
50
+ """Drive the motor until it stalls, ignoring the calibrated end positions.
51
+
52
+ This is the only control that still works on a jammed lock, or on one whose
53
+ calibration is gone. The direction is a raw rotation rather than a target, so
54
+ the lock does not know which end it is heading for -- `reverse` turns it the
55
+ other way, which is what the app offers once the bolt has gone the wrong way.
56
+ It leaves the lock uncalibrated, so a calibration run has to follow.
57
+ """
58
+ return bytes([0x57, 0x0F, 0x4E, 0x01, 0x01, SINGLE, 0x01, 0xC0 if reverse else 0x40])
59
+
60
+
61
+ # --- calibration ---
62
+ # The recorded positions live in the connection. Sent one per connection every step
63
+ # still answers "accepted" while nothing is kept, and the commit then leaves the lock
64
+ # uncalibrated, so the whole run has to hold one link.
65
+
66
+
67
+ def enter_calibration() -> bytes:
68
+ """Release the motor so the knob turns by hand, and start recording."""
69
+ return bytes.fromhex("570f4e030100c0")
70
+
71
+
72
+ def set_lock_position() -> bytes:
73
+ """Record where the knob is now as locked, and the door as closed."""
74
+ return bytes.fromhex("570f4e030400c000")
75
+
76
+
77
+ def set_unlock_position() -> bytes:
78
+ """Record where the knob is now as unlocked."""
79
+ return bytes.fromhex("570f4e0304008080")
80
+
81
+
82
+ def set_door_open_position() -> bytes:
83
+ """Record the door as open, for the door sensor."""
84
+ return bytes.fromhex("570f4e0304004040")
85
+
86
+
87
+ def test_unlock() -> bytes:
88
+ """Drive to the recorded unlock position, still inside calibration."""
89
+ return bytes.fromhex("570f4e03030080")
90
+
91
+
92
+ def test_lock() -> bytes:
93
+ """Drive to the recorded lock position, still inside calibration."""
94
+ return bytes.fromhex("570f4e03030000")
95
+
96
+
97
+ def set_latch_type(latch: LatchType) -> bytes:
98
+ """Tell the lock which latch it drives. A calibration step, not a setting.
99
+
100
+ The lock detects the type itself and reports it; the app only confirms what it
101
+ already said. Writing a type it has not reported is not something it corrects.
102
+ On the Lock Pro the app leaves this frame unsent -- its screens never reach the
103
+ call, though the firmware answers it -- so it is opt-in here.
104
+ """
105
+ if latch is LatchType.UNKNOWN:
106
+ raise ProtocolError("UNKNOWN is what the lock reports, not something to write")
107
+ return bytes([0x57, 0x0F, 0x4E, 0x04, 0x08, int(latch)])
108
+
109
+
110
+ def finish_calibration() -> bytes:
111
+ """Commit the recorded positions and leave calibration mode."""
112
+ return bytes.fromhex("570f4e030200")
113
+
114
+
115
+ def recover_calibration() -> bytes:
116
+ """Abandon a calibration run in progress.
117
+
118
+ Not a way back to a calibration that is already lost: an uncalibrated lock
119
+ accepts this and nothing changes.
120
+ """
121
+ return bytes.fromhex("570f4e030600")
122
+
123
+
124
+ # --- settings ---
125
+ # Sub-commands 0x03 and 0x04 mean the opposite on the base Lock, and there 0x09 is the
126
+ # latch time rather than the battery bays. A base-Lock frame is not refused here, only
127
+ # answered wrongly, and the shape of the reply is what gives it away.
128
+
129
+
130
+ def read_flags() -> bytes:
131
+ """Read the settings byte: light, sound, linkage and the button."""
132
+ return bytes.fromhex("570f4f0401")
133
+
134
+
135
+ def read_auto_lock() -> bytes:
136
+ """Read the auto-lock delay."""
137
+ return bytes.fromhex("570f4f0402")
138
+
139
+
140
+ def read_force_lock() -> bytes:
141
+ """Read the force-lock delay."""
142
+ return bytes.fromhex("570f4f0403")
143
+
144
+
145
+ def read_auto_lock_paused() -> bytes:
146
+ """Read whether auto-lock is suspended."""
147
+ return bytes.fromhex("570f4f0404")
148
+
149
+
150
+ def read_latch_keep_time() -> bytes:
151
+ """Read how long the latch stays retracted."""
152
+ return bytes.fromhex("570f4f0407")
153
+
154
+
155
+ def read_battery_bays() -> bytes:
156
+ """Read the charge in each battery bay."""
157
+ return bytes.fromhex("570f4f0409")
158
+
159
+
160
+ def read_alert(alert: LockAlert) -> bytes:
161
+ """Read one alert's flag and delay."""
162
+ return bytes([0x57, 0x0F, 0x4F, 0x05, int(alert)])
163
+
164
+
165
+ def _timed(enabled: bool, seconds: int) -> bytes:
166
+ """The (flag, delay) pair every timed setting uses.
167
+
168
+ The seconds go in split: whatever is under a minute rides in the low bits of the
169
+ flag byte, and whole minutes follow in the next one.
170
+ """
171
+ minutes, rest = divmod(max(seconds, 0), 60)
172
+ return bytes([(0x80 if enabled else 0) | rest, minutes & 0xFF])
173
+
174
+
175
+ def set_flags(
176
+ light: bool,
177
+ sound: bool,
178
+ lock_go: bool = False,
179
+ linkage: bool = False,
180
+ device: int = SINGLE,
181
+ ) -> bytes:
182
+ """Write all four flags at once, with 0xF0 naming them in the mask byte.
183
+
184
+ The app's own builder never sets the lock-go bit, so 0x20 for it is read off the
185
+ bit order rather than off a call.
186
+ """
187
+ flags = (0x80 if light else 0) | (0x40 if sound else 0)
188
+ flags |= (0x20 if lock_go else 0) | (0x10 if linkage else 0)
189
+ return bytes([0x57, 0x0F, 0x4E, 0x04, 0x01, device & 0xFF, 0xF0, flags])
190
+
191
+
192
+ def set_light(enabled: bool, device: int = SINGLE) -> bytes:
193
+ """The indicator light on the lock body."""
194
+ return bytes([0x57, 0x0F, 0x4E, 0x04, 0x01, device & 0xFF, 0x80, 0x80 if enabled else 0x00])
195
+
196
+
197
+ def set_sound(enabled: bool, device: int = SINGLE) -> bytes:
198
+ """Beeps and voice prompts."""
199
+ return bytes([0x57, 0x0F, 0x4E, 0x04, 0x01, device & 0xFF, 0x40, 0x40 if enabled else 0x00])
200
+
201
+
202
+ def set_manual_unlock_linkage(enabled: bool, device: int = SINGLE) -> bytes:
203
+ """Report a by-hand unlock to whatever is linked to the lock."""
204
+ return bytes([0x57, 0x0F, 0x4E, 0x04, 0x01, device & 0xFF, 0x10, 0x10 if enabled else 0x00])
205
+
206
+
207
+ def set_key(
208
+ enabled: bool,
209
+ action: KeyAction = KeyAction.TOGGLE,
210
+ trigger: KeyTrigger = KeyTrigger.SINGLE,
211
+ device: int = SINGLE,
212
+ ) -> bytes:
213
+ """What the lock's own button does, and how many presses it wants.
214
+
215
+ Switching it off narrows the mask to the enable bit, so the action and trigger
216
+ the lock remembers survive. The 0x0E mask names the button field as a whole and
217
+ carries the action's low bit with it.
218
+ """
219
+ if not enabled:
220
+ return bytes([0x57, 0x0F, 0x4E, 0x04, 0x01, device & 0xFF, 0x08, 0x00])
221
+ value = 0x08 | (int(trigger) << 2) | int(action)
222
+ return bytes([0x57, 0x0F, 0x4E, 0x04, 0x01, device & 0xFF, 0x0E, value])
223
+
224
+
225
+ def set_auto_lock(enabled: bool, seconds: int = 0, device: int = SINGLE) -> bytes:
226
+ """Lock again by itself, `seconds` after being unlocked."""
227
+ return bytes([0x57, 0x0F, 0x4E, 0x04, 0x02, device & 0xFF]) + _timed(enabled, seconds)
228
+
229
+
230
+ def set_force_lock(enabled: bool, seconds: int = 0, device: int = SINGLE) -> bytes:
231
+ """Lock even when the door is reported open, after `seconds`."""
232
+ return bytes([0x57, 0x0F, 0x4E, 0x04, 0x03, device & 0xFF]) + _timed(enabled, seconds)
233
+
234
+
235
+ def set_auto_lock_paused(paused: bool, device: int = SINGLE) -> bytes:
236
+ """Suspend auto-lock without making it forget its delay."""
237
+ return bytes([0x57, 0x0F, 0x4E, 0x04, 0x04, device & 0xFF, 0x80 if paused else 0x00])
238
+
239
+
240
+ def set_alert(
241
+ alert: LockAlert, enabled: bool, seconds: int = 0, device: int = SINGLE
242
+ ) -> bytes:
243
+ """Arm one alert and give it its delay."""
244
+ return bytes([0x57, 0x0F, 0x4E, 0x05, int(alert), device & 0xFF]) + _timed(enabled, seconds)
245
+
246
+
247
+ def set_latch_keep_time(seconds: int) -> bytes:
248
+ """How long the latch stays retracted before it springs back."""
249
+ return bytes([0x57, 0x0F, 0x4E, 0x04, 0x07, 0xFF, seconds & 0xFF])
@@ -0,0 +1,136 @@
1
+ """Structured results the Lock Pro parsers return."""
2
+
3
+ from opensb.ble.models import Frozen
4
+ from opensb.lockpro.enums import KeyAction, KeyTrigger, LockStatus, LogSource
5
+ from pydantic import Field, field_validator
6
+
7
+
8
+ class Advertisement(Frozen):
9
+ """What the lock broadcasts without any key."""
10
+
11
+ status: LockStatus = Field(description="Where the bolt is")
12
+ calibrated: bool = Field(description="Whether the lock knows its travel")
13
+ battery: int = Field(description="Primary cell percentage")
14
+ charging: bool = Field(description="Whether the lock is on charge")
15
+ grouped: bool = Field(description="Part of a dual-lock group")
16
+ auto_lock_paused: bool = Field(description="Auto-lock suspended")
17
+ door_open_alert: bool = Field(description="Door left open")
18
+ unlocked_alert: bool = Field(description="Left unlocked")
19
+ low_temp_alert: bool = Field(description="Battery too cold to work")
20
+ weak_drive_alert: bool = Field(description="Not enough power to move the bolt")
21
+ door_magnet_alert: bool = Field(description="Door-close sensor alarm")
22
+ left_battery_low: bool = Field(description="Left cell low; the Lock Pro has two")
23
+ right_battery_low: bool = Field(description="Right cell low")
24
+ sequence: int = Field(description="Broadcast counter")
25
+ action_source: int = Field(description="What caused the last movement")
26
+
27
+
28
+ class LockInfo(Frozen):
29
+ """The lock's answer to a status read.
30
+
31
+ Five fields are decoded; the rest is handed back as `tail` rather than given
32
+ invented meanings. Battery has its own command; see `Lock.battery`.
33
+ """
34
+
35
+ status: LockStatus = Field(description="Where the bolt is")
36
+ calibrated: bool = Field(description="Whether the lock knows its travel")
37
+ door_open: bool = Field(description="Door reported open")
38
+ door_not_closed_alert: bool = Field(description="Door left open alarm")
39
+ not_locked_alert: bool = Field(description="Left unlocked alarm")
40
+ tail: bytes = Field(description="The undecoded remainder of the reply")
41
+
42
+
43
+ class Battery(Frozen):
44
+ """Battery and firmware, from the short read."""
45
+
46
+ battery: int = Field(description="Percentage")
47
+ firmware: float = Field(description="Firmware version")
48
+
49
+
50
+ class LogEntry(Frozen):
51
+ """One entry of the lock's event log.
52
+
53
+ `source` and `action` are raw. The direction of a credential event has not been
54
+ confirmed against a labelled action, so it is not derived here.
55
+ """
56
+
57
+ at: int = Field(description="Unix seconds")
58
+ index: int = Field(description="Entry index")
59
+ source: LogSource | int = Field(description="What raised the entry")
60
+ action: int = Field(description="Action byte")
61
+ value: int = Field(description="Direction and slot bits")
62
+ payload: bytes = Field(default=b"", description="Extra bytes, used by some sources")
63
+
64
+ @field_validator("source", mode="before")
65
+ @classmethod
66
+ def _name_the_source(cls, value: int) -> LogSource | int:
67
+ try:
68
+ return LogSource(value)
69
+ except ValueError:
70
+ return value
71
+
72
+ @property
73
+ def credential_ref(self) -> bytes | None:
74
+ """The key reference a credential event carries, which names the credential."""
75
+ if self.source is not LogSource.CREDENTIAL or len(self.payload) < 3:
76
+ return None
77
+ return self.payload[2:]
78
+
79
+
80
+ class LockFlags(Frozen):
81
+ """The settings byte, which holds seven fields at once.
82
+
83
+ Every write names in byte 6 the field it touches, so `raw` is kept: it is the
84
+ only way to see bits nothing here decodes.
85
+ """
86
+
87
+ light: bool = Field(description="Indicator light on the lock body")
88
+ sound: bool = Field(description="Beeps and voice prompts")
89
+ lock_go: bool = Field(description="Lock-go; the app never sets this bit")
90
+ linkage: bool = Field(description="Report a by-hand unlock to linked devices")
91
+ key_enabled: bool = Field(description="Whether the lock's button responds")
92
+ key_trigger: KeyTrigger = Field(description="One press or two")
93
+ key_action: KeyAction | int = Field(description="What a press does")
94
+ raw: int = Field(description="The byte as it arrived")
95
+
96
+ @field_validator("key_action", mode="before")
97
+ @classmethod
98
+ def _name_the_action(cls, value: int) -> KeyAction | int:
99
+ try:
100
+ return KeyAction(value)
101
+ except ValueError:
102
+ return value
103
+
104
+
105
+ class TimedSetting(Frozen):
106
+ """A setting that is on or off and acts after a delay."""
107
+
108
+ enabled: bool = Field(description="Whether it is armed")
109
+ seconds: int = Field(description="Delay before it acts")
110
+
111
+
112
+ class BatteryBay(Frozen):
113
+ """One of the Lock Pro's two battery bays."""
114
+
115
+ inserted: bool = Field(description="Whether a cell sits in the bay")
116
+ battery: int = Field(description="Percentage")
117
+
118
+
119
+ class BatteryBays(Frozen):
120
+ """Both bays. A grouped lock appends the slave's; only the master's are read."""
121
+
122
+ left: BatteryBay = Field(description="Left bay")
123
+ right: BatteryBay = Field(description="Right bay")
124
+
125
+
126
+ class Settings(Frozen):
127
+ """Every configuration value the lock will report, read one at a time."""
128
+
129
+ flags: LockFlags = Field(description="Light, sound, linkage and the button")
130
+ auto_lock: TimedSetting = Field(description="Lock again by itself after a delay")
131
+ force_lock: TimedSetting = Field(description="Lock even with the door reported open")
132
+ auto_lock_paused: bool = Field(description="Auto-lock suspended, delay remembered")
133
+ latch_keep_time: int = Field(description="Seconds the latch is held back")
134
+ door_left_open_alert: TimedSetting = Field(description="Alarm for a door left open")
135
+ not_locked_alert: TimedSetting = Field(description="Alarm for a lock left open")
136
+ battery_bays: BatteryBays = Field(description="Charge in each bay")
File without changes
@@ -0,0 +1,91 @@
1
+ """Turn decrypted Lock Pro replies into models."""
2
+
3
+ from opensb.ble.replies import check
4
+ from opensb.lockpro.advertisement import status_from_byte
5
+ from opensb.lockpro.enums import KeyTrigger
6
+ from opensb.lockpro.models import (
7
+ Battery,
8
+ BatteryBay,
9
+ BatteryBays,
10
+ LockFlags,
11
+ LockInfo,
12
+ LogEntry,
13
+ TimedSetting,
14
+ )
15
+
16
+
17
+ def parse_info(reply: bytes) -> LockInfo:
18
+ """Decode an info() reply. Byte 1 carries the same status bits as the advertisement."""
19
+ check(reply, "lock info", minimum=7)
20
+ return LockInfo(
21
+ status=status_from_byte(reply[1]),
22
+ calibrated=bool(reply[1] & 0x80),
23
+ door_open=bool(reply[2] & 0x10),
24
+ door_not_closed_alert=bool(reply[6] & 0x80),
25
+ not_locked_alert=bool(reply[6] & 0x40),
26
+ tail=reply[3:],
27
+ )
28
+
29
+
30
+ def parse_battery(reply: bytes) -> Battery:
31
+ """Decode a battery() reply. Byte 3 is unexplained on this firmware."""
32
+ check(reply, "battery", minimum=3)
33
+ return Battery(battery=reply[1], firmware=reply[2] / 10)
34
+
35
+
36
+ def parse_log_entry(reply: bytes) -> LogEntry | None:
37
+ """Decode one log entry, or None once the log has run out."""
38
+ check(reply, "read log")
39
+ if len(reply) < 9 or not any(reply[1:]):
40
+ return None
41
+ return LogEntry(
42
+ at=int.from_bytes(reply[1:5], "big"),
43
+ index=reply[5],
44
+ source=reply[6],
45
+ action=reply[7],
46
+ value=reply[8],
47
+ payload=reply[9:],
48
+ )
49
+
50
+
51
+ def parse_flags(reply: bytes) -> LockFlags:
52
+ """Decode a read_flags() reply. One byte carries all seven fields."""
53
+ check(reply, "lock flags", minimum=2)
54
+ flags = reply[1]
55
+ return LockFlags(
56
+ light=bool(flags & 0x80),
57
+ sound=bool(flags & 0x40),
58
+ lock_go=bool(flags & 0x20),
59
+ linkage=bool(flags & 0x10),
60
+ key_enabled=bool(flags & 0x08),
61
+ key_trigger=KeyTrigger(bool(flags & 0x04)),
62
+ key_action=flags & 0x03,
63
+ raw=flags,
64
+ )
65
+
66
+
67
+ def parse_timed_setting(reply: bytes, context: str = "timed setting") -> TimedSetting:
68
+ """Decode the (flag, delay) pair auto-lock, force-lock and the alerts share."""
69
+ check(reply, context, minimum=3)
70
+ return TimedSetting(enabled=bool(reply[1] & 0x80), seconds=(reply[1] & 0x3F) + reply[2] * 60)
71
+
72
+
73
+ def parse_auto_lock_paused(reply: bytes) -> bool:
74
+ """Decode a read_auto_lock_paused() reply, which is one flag byte."""
75
+ check(reply, "auto-lock paused", minimum=2)
76
+ return bool(reply[1] & 0x80)
77
+
78
+
79
+ def parse_latch_keep_time(reply: bytes) -> int:
80
+ """Decode a read_latch_keep_time() reply. The seconds sit in byte 2."""
81
+ check(reply, "latch keep time", minimum=3)
82
+ return reply[2]
83
+
84
+
85
+ def parse_battery_bays(reply: bytes) -> BatteryBays:
86
+ """Decode a read_battery_bays() reply: one byte per bay, seated flag in bit 7."""
87
+ check(reply, "battery bays", minimum=3)
88
+ return BatteryBays(
89
+ left=BatteryBay(inserted=bool(reply[1] & 0x80), battery=reply[1] & 0x7F),
90
+ right=BatteryBay(inserted=bool(reply[2] & 0x80), battery=reply[2] & 0x7F),
91
+ )