opensb 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/
opensb-0.0.1/LICENSE ADDED
@@ -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.
opensb-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,35 @@
1
+ Metadata-Version: 2.5
2
+ Name: opensb
3
+ Version: 0.0.1
4
+ Summary: Command line for SwitchBot devices over BLE, with no cloud and no state on disk
5
+ Author-email: Yorsh Siarhei <yorsh.srg@gmail.com>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: ble,bluetooth,cli,home-assistant,switchbot
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Classifier: Topic :: Home Automation
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.14
16
+ Requires-Dist: opensb-core[ble]
17
+ Requires-Dist: rich>=13
18
+ Requires-Dist: typer>=0.12
19
+ Provides-Extra: all
20
+ Requires-Dist: opensb-keypad; extra == 'all'
21
+ Requires-Dist: opensb-lockpro; extra == 'all'
22
+ Provides-Extra: keypad
23
+ Requires-Dist: opensb-keypad; extra == 'keypad'
24
+ Provides-Extra: lockpro
25
+ Requires-Dist: opensb-lockpro; extra == 'lockpro'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # opensb
29
+
30
+ Command line for SwitchBot devices over BLE.
31
+
32
+ python3 -m pip install 'opensb[keypad,lockpro]'
33
+
34
+ Device support is a separate distribution each, so a group appears in the CLI only
35
+ when its package is installed.
opensb-0.0.1/README.md ADDED
@@ -0,0 +1,8 @@
1
+ # opensb
2
+
3
+ Command line for SwitchBot devices over BLE.
4
+
5
+ python3 -m pip install 'opensb[keypad,lockpro]'
6
+
7
+ Device support is a separate distribution each, so a group appears in the CLI only
8
+ when its package is installed.
@@ -0,0 +1,38 @@
1
+ [project]
2
+ name = "opensb"
3
+ dynamic = ["version"]
4
+ description = "Command line for SwitchBot devices over BLE, with no cloud and no state on disk"
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", "ble", "bluetooth", "cli", "home-assistant"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Environment :: Console",
14
+ "Intended Audience :: Developers",
15
+ "Programming Language :: Python :: 3.14",
16
+ "Topic :: Home Automation",
17
+ "Typing :: Typed",
18
+ ]
19
+ dependencies = ["opensb-core[ble]", "typer>=0.12", "rich>=13"]
20
+
21
+ [project.optional-dependencies]
22
+ keypad = ["opensb-keypad"]
23
+ lockpro = ["opensb-lockpro"]
24
+ all = ["opensb[keypad,lockpro]"]
25
+
26
+ [project.scripts]
27
+ opensb = "opensb.cli.main:main"
28
+
29
+ [build-system]
30
+ requires = ["hatchling", "hatch-vcs"]
31
+ build-backend = "hatchling.build"
32
+
33
+ [tool.hatch.version]
34
+ source = "vcs"
35
+ raw-options = { root = "../..", fallback_version = "0.0.0" }
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/opensb"]
@@ -0,0 +1 @@
1
+ """The `opensb` command line."""
@@ -0,0 +1,183 @@
1
+ """Plumbing every command group shares: the target, the runner, the renderer."""
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import time
7
+ from collections.abc import Awaitable, Generator, Mapping
8
+ from contextlib import contextmanager
9
+ from datetime import date, datetime
10
+ from pathlib import Path
11
+ from typing import Annotated
12
+
13
+ import typer
14
+ from opensb.ble.errors import OpenSBError
15
+ from opensb.ble.models import CommunicationKey
16
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
17
+ from rich.console import Console
18
+ from rich.table import Table
19
+ from rich.text import Text
20
+
21
+ console = Console()
22
+ error_console = Console(stderr=True, style="bold red")
23
+
24
+ KEY_FILE_ENV = "OPENSB_KEY_FILE"
25
+
26
+ KeyFile = Annotated[
27
+ Path | None,
28
+ typer.Option(
29
+ "--key-file",
30
+ help=f"Communication key JSON, or set {KEY_FILE_ENV}.",
31
+ show_default=False,
32
+ ),
33
+ ]
34
+ Mac = Annotated[
35
+ str | None, typer.Option("--mac", help="Address to talk to, overriding the key file's.")
36
+ ]
37
+ Adapter = Annotated[str | None, typer.Option("--adapter", help="Bluetooth adapter to use.")]
38
+ Minutes = Annotated[int, typer.Option("--minutes", help="Validity window, in minutes.")]
39
+
40
+
41
+ class Target(BaseModel):
42
+ """Where to find the device. Built per invocation and carried on the typer context."""
43
+
44
+ model_config = ConfigDict(frozen=True)
45
+
46
+ key_file: Path | None = Field(default=None, description="Key JSON the caller pointed at")
47
+ mac: str | None = Field(default=None, description="Address overriding the key file's")
48
+ adapter: str | None = Field(default=None, description="Bluetooth adapter to use")
49
+
50
+ def key(self) -> CommunicationKey:
51
+ """Read the key the caller pointed at."""
52
+ from_env = os.environ.get(KEY_FILE_ENV)
53
+ path = self.key_file or (Path(from_env) if from_env else None)
54
+ if path is None:
55
+ raise OpenSBError(
56
+ f"no key given: pass --key-file, or set {KEY_FILE_ENV}.\n"
57
+ "The communication key is minted per device by SwitchBot against your "
58
+ "account; this tool does not fetch it."
59
+ )
60
+ try:
61
+ payload = json.loads(path.read_text())
62
+ except FileNotFoundError as err:
63
+ raise OpenSBError(f"no key file at {path}") from err
64
+ except json.JSONDecodeError as err:
65
+ raise OpenSBError(f"{path} is not valid JSON: {err}") from err
66
+ try:
67
+ key = CommunicationKey.model_validate(payload)
68
+ except (ValidationError, ValueError) as err:
69
+ raise OpenSBError(f"{path} is not a key file: {err}") from err
70
+ return key.model_copy(update={"mac": self.mac}) if self.mac else key
71
+
72
+
73
+ def target(context: typer.Context) -> Target:
74
+ """The invocation's target, as the root callback built it."""
75
+ assert isinstance(context.obj, Target)
76
+ return context.obj
77
+
78
+
79
+ def run[T](coroutine: Awaitable[T]) -> T:
80
+ """Run a command, turning our errors into a clean exit rather than a traceback."""
81
+ try:
82
+ return asyncio.run(coroutine)
83
+ except OpenSBError as err:
84
+ error_console.print(str(err))
85
+ raise typer.Exit(1) from None
86
+ except KeyboardInterrupt:
87
+ error_console.print("interrupted")
88
+ raise typer.Exit(130) from None
89
+
90
+
91
+ @contextmanager
92
+ def progress(message: str) -> Generator[None]:
93
+ """A spinner, but only for a human watching a terminal."""
94
+ if not console.is_terminal:
95
+ yield
96
+ return
97
+ with console.status(message):
98
+ yield
99
+
100
+
101
+ class Rows:
102
+ """Tabular output that suits whoever is reading it.
103
+
104
+ Aligned and borderless on a terminal; tab separated with no padding, markup or
105
+ headings when piped, so each line is one record for `grep` and `cut`.
106
+ """
107
+
108
+ def __init__(self, *columns: str, headers: bool = True) -> None:
109
+ self.columns = columns
110
+ self.headers = headers
111
+ self._rows: list[tuple[str, ...]] = []
112
+ self._sections: set[int] = set()
113
+
114
+ def add(self, *cells: str) -> None:
115
+ self._rows.append(cells)
116
+
117
+ def section(self) -> None:
118
+ """A blank rule before the next row. Terminal only."""
119
+ self._sections.add(len(self._rows))
120
+
121
+ def __bool__(self) -> bool:
122
+ return bool(self._rows)
123
+
124
+ def render(self) -> None:
125
+ if not console.is_terminal:
126
+ for row in self._rows:
127
+ print("\t".join(Text.from_markup(cell).plain for cell in row))
128
+ return
129
+ table = Table(box=None, pad_edge=False, show_header=self.headers, header_style="dim")
130
+ for column in self.columns:
131
+ table.add_column(column, overflow="fold")
132
+ for index, row in enumerate(self._rows):
133
+ if index in self._sections:
134
+ table.add_section()
135
+ table.add_row(*row)
136
+ console.print(table)
137
+
138
+
139
+ def day(unix_seconds: int) -> str:
140
+ """The entry's date, as a heading. Today and yesterday are named."""
141
+ today = date.today()
142
+ when_ = datetime.fromtimestamp(unix_seconds).date()
143
+ if when_ == today:
144
+ return "today"
145
+ if (today - when_).days == 1:
146
+ return "yesterday"
147
+ return when_.strftime("%A %d %B" if when_.year == today.year else "%d %B %Y")
148
+
149
+
150
+ def when(unix_seconds: int) -> str:
151
+ if not unix_seconds:
152
+ return "-"
153
+ return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(unix_seconds))
154
+
155
+
156
+ def name(value: object, *vocabularies: Mapping[str, object]) -> str:
157
+ """Render a value the way it is typed in.
158
+
159
+ A vocabulary maps the words a command accepts onto enum members; the word wins
160
+ where there is one, so `--type temporary` reads back as `temporary`.
161
+ """
162
+ for vocabulary in vocabularies:
163
+ for word, member in vocabulary.items():
164
+ if member is value:
165
+ return word
166
+ label = getattr(value, "name", None)
167
+ return label.lower().replace("_", " ") if label else str(value)
168
+
169
+
170
+ def choice[T](table: Mapping[str, T], value: str, hint: str) -> T:
171
+ try:
172
+ return table[value]
173
+ except KeyError:
174
+ raise typer.BadParameter(
175
+ f"expected one of {', '.join(sorted(table))}", param_hint=hint
176
+ ) from None
177
+
178
+
179
+ def switch(state: str) -> bool:
180
+ """Read a two-position setting, in the same 1 and 0 the tool prints back."""
181
+ if state not in ("1", "0"):
182
+ raise typer.BadParameter("expected 1 or 0", param_hint="state")
183
+ return state == "1"
@@ -0,0 +1,533 @@
1
+ """`opensb keypad` -- passcodes, cards, fingerprints and the keypad's own log."""
2
+
3
+ import time
4
+ from collections.abc import Awaitable, Callable
5
+ from typing import Annotated
6
+
7
+ import typer
8
+ from opensb.ble.models import Window
9
+ from opensb.cli.common import (
10
+ Minutes,
11
+ Rows,
12
+ Target,
13
+ choice,
14
+ console,
15
+ day,
16
+ name,
17
+ progress,
18
+ run,
19
+ switch,
20
+ target,
21
+ when,
22
+ )
23
+ from opensb.keypad import discovery
24
+ from opensb.keypad.device import FINGER_COLLECT_SECONDS, Keypad
25
+ from opensb.keypad.enums import (
26
+ Backlight,
27
+ CredentialKind,
28
+ FingerType,
29
+ LockButton,
30
+ NfcType,
31
+ PasswordType,
32
+ )
33
+
34
+ # Passcodes and cards are numbered on separate scales -- "emergency" is 3 for a code
35
+ # and 2 for a card -- so the tables stay apart.
36
+ PASSWORD_TYPES = {
37
+ "permanent": PasswordType.PERMANENT,
38
+ "temporary": PasswordType.TIME_LIMIT,
39
+ "onetime": PasswordType.ONCE,
40
+ "emergency": PasswordType.URGENT,
41
+ }
42
+ NFC_TYPES = {
43
+ "permanent": NfcType.PERMANENT,
44
+ "temporary": NfcType.TIME_LIMIT,
45
+ "emergency": NfcType.URGENT,
46
+ }
47
+ FINGER_TYPES = {
48
+ "permanent": FingerType.PERMANENT,
49
+ "temporary": FingerType.TIME_LIMIT,
50
+ "emergency": FingerType.URGENT,
51
+ }
52
+ CREDENTIAL_KINDS = {
53
+ "password": CredentialKind.PASSWORD,
54
+ "card": CredentialKind.NFC,
55
+ "finger": CredentialKind.FINGERPRINT,
56
+ }
57
+ BACKLIGHT_MODES = {"off": Backlight.OFF, "auto": Backlight.AUTO, "on": Backlight.ALWAYS_ON}
58
+ LOCK_BUTTON_STATES = {
59
+ "off": LockButton.DISABLED,
60
+ "on": LockButton.ENABLED,
61
+ "timed": LockButton.TIMED,
62
+ }
63
+
64
+ FORCE_WARNING = (
65
+ "--force makes the keypad write its own data ONTO the card, destroying whatever it "
66
+ "held. Only use a blank or expendable card -- never a transit pass, bank card or "
67
+ "office badge."
68
+ )
69
+
70
+ app = typer.Typer(help=__doc__, no_args_is_help=True)
71
+ code_app = typer.Typer(help="Passcodes stored in the keypad.", no_args_is_help=True)
72
+ card_app = typer.Typer(help="NFC cards enrolled on the keypad.", no_args_is_help=True)
73
+ finger_app = typer.Typer(help="Fingerprints on the keypad.", no_args_is_help=True)
74
+ settings_app = typer.Typer(help="Device settings.", no_args_is_help=True)
75
+ app.add_typer(code_app, name="code")
76
+ app.add_typer(card_app, name="card")
77
+ app.add_typer(finger_app, name="finger")
78
+ app.add_typer(settings_app, name="settings")
79
+
80
+
81
+ def with_keypad[T](target_: Target, action: Callable[[Keypad], Awaitable[T]]) -> T:
82
+ """Connect to the keypad `target_` names, run `action`, disconnect."""
83
+
84
+ async def go() -> T:
85
+ async with Keypad.over_ble(target_.key(), adapter=target_.adapter) as keypad:
86
+ return await action(keypad)
87
+
88
+ return run(go())
89
+
90
+
91
+ # --- discovery and keys ---
92
+
93
+
94
+ @app.command()
95
+ def scan(
96
+ seconds: Annotated[float, typer.Option(help="How long to listen.")] = 10.0,
97
+ ) -> None:
98
+ """List Keypad Touch devices in range, with what they broadcast."""
99
+ keypads = run(discovery.discover(seconds))
100
+ if not keypads:
101
+ console.print("no keypad seen -- press a key on it to wake it, then scan again")
102
+ return
103
+ rows = Rows("address", "name", "rssi", "battery", "alerts")
104
+ for keypad in keypads:
105
+ alerts = [
106
+ field
107
+ for field, flagged in keypad.state.model_dump().items()
108
+ if field.endswith("_alert") and flagged
109
+ ]
110
+ rows.add(
111
+ keypad.address,
112
+ keypad.name or "",
113
+ str(keypad.rssi),
114
+ f"{keypad.state.battery}%",
115
+ ", ".join(alerts) or "-",
116
+ )
117
+ rows.render()
118
+
119
+
120
+ # --- the device ---
121
+
122
+
123
+ @app.command()
124
+ def info(context: typer.Context) -> None:
125
+ """Battery, versions and every device setting."""
126
+
127
+ async def action(keypad: Keypad) -> None:
128
+ device = await keypad.info()
129
+ quick = await keypad.quick_unlock()
130
+ rows = Rows("setting", "value", headers=False)
131
+ rows.add("battery", f"{device.battery}%")
132
+ rows.add("versions", f"ble {device.ble_version}, hardware {device.hardware_version}")
133
+ rows.add("fingerprint reader", f"{device.has_fingerprint:d}")
134
+ # The window is reported whatever the state, so it is shown whenever it is set.
135
+ window = f" ({device.lock_button_seconds}s)" if device.lock_button_seconds else ""
136
+ rows.add("lock button", f"{name(device.lock_button, LOCK_BUTTON_STATES)}{window}")
137
+ rows.add("keypad", f"{not device.keyboard_disabled:d}")
138
+ rows.add("removal alarm", f"{device.removal_alarm:d}")
139
+ rows.add(
140
+ "backlight",
141
+ f"{name(device.backlight, BACKLIGHT_MODES)}, level {device.backlight_level}",
142
+ )
143
+ rows.add("sound", f"{device.sound:d}")
144
+ rows.add("time penalty", str(device.time_penalty))
145
+ rows.add("quick unlock", f"{quick:d}")
146
+ rows.render()
147
+
148
+ with_keypad(target(context), action)
149
+
150
+
151
+ @app.command()
152
+ def status(context: typer.Context) -> None:
153
+ """What each credential engine is doing right now."""
154
+
155
+ async def action(keypad: Keypad) -> None:
156
+ state = await keypad.status()
157
+ rows = Rows("engine", "status", "detail", headers=False)
158
+ rows.add("passcode", name(state.password), "")
159
+ rows.add("card", name(state.nfc), "")
160
+ rows.add(
161
+ "fingerprint",
162
+ name(state.fingerprint),
163
+ f"{state.fingerprint_step} press(es), verdicts {state.fingerprint_step_results}",
164
+ )
165
+ rows.add("face", str(state.face), f"error {state.face_error}")
166
+ rows.render()
167
+
168
+ with_keypad(target(context), action)
169
+
170
+
171
+ @app.command("log")
172
+ def read_log(
173
+ context: typer.Context,
174
+ limit: Annotated[int, typer.Option(help="How many entries to read.")] = 20,
175
+ since: Annotated[
176
+ int | None, typer.Option(help="Read entries older than this unix time.")
177
+ ] = None,
178
+ raw: Annotated[
179
+ bool, typer.Option("--raw", help="Show the source, action and value bytes too.")
180
+ ] = False,
181
+ ) -> None:
182
+ """Walk the keypad's own event log, newest first."""
183
+
184
+ async def action(keypad: Keypad) -> None:
185
+ # A piped line has to stand on its own, so the date goes into the row there.
186
+ grouped = console.is_terminal
187
+ clock, stamp = "%H:%M:%S", "%Y-%m-%d %H:%M:%S"
188
+ columns = ["time", "event"] + (["src", "act", "val"] if raw else [])
189
+ rows = Rows(*columns)
190
+ shown = None
191
+
192
+ async for entry in keypad.read_log(since, limit):
193
+ if grouped and (heading := day(entry.at)) != shown:
194
+ rows.section()
195
+ rows.add(f"[bold]{heading}[/bold]", "")
196
+ shown = heading
197
+ summary = entry.summary
198
+ cells = [
199
+ time.strftime(clock if grouped else stamp, time.localtime(entry.at)),
200
+ f"[red]{summary}[/red]" if entry.is_error else summary,
201
+ ]
202
+ if raw:
203
+ cells += [str(int(entry.source)), str(entry.action), str(entry.value)]
204
+ rows.add(*cells)
205
+ rows.render() if rows else console.print("the log is empty")
206
+
207
+ with_keypad(target(context), action)
208
+
209
+
210
+ # --- passcodes ---
211
+
212
+
213
+ @code_app.command("list")
214
+ def code_list(
215
+ context: typer.Context,
216
+ slots: Annotated[int, typer.Option(help="How many slots to walk.")] = 100,
217
+ ) -> None:
218
+ """List every stored passcode. Read-only, and about a minute for a full sweep."""
219
+
220
+ async def action(keypad: Keypad) -> None:
221
+ rows = Rows("slot", "code", "type", "from", "until")
222
+ with progress(f"walking {slots} slots…"):
223
+ async for stored in keypad.list_passcodes(slots):
224
+ rows.add(
225
+ str(stored.slot),
226
+ stored.code,
227
+ name(stored.password_type, PASSWORD_TYPES),
228
+ when(stored.starts_at),
229
+ when(stored.ends_at),
230
+ )
231
+ rows.render() if rows else console.print("no passcodes stored")
232
+
233
+ with_keypad(target(context), action)
234
+
235
+
236
+ @code_app.command("get")
237
+ def code_get(
238
+ context: typer.Context, slot: Annotated[int, typer.Argument(help="Slot to read.")]
239
+ ) -> None:
240
+ """Read one passcode slot."""
241
+
242
+ async def action(keypad: Keypad) -> None:
243
+ stored = await keypad.read_passcode(slot)
244
+ if stored is None:
245
+ console.print(f"slot {slot} is empty")
246
+ return
247
+ kind = name(stored.password_type, PASSWORD_TYPES)
248
+ console.print(
249
+ f"slot {slot}: [bold]{stored.code}[/bold] ({kind}), "
250
+ f"{when(stored.starts_at)} → {when(stored.ends_at)}"
251
+ )
252
+
253
+ with_keypad(target(context), action)
254
+
255
+
256
+ @code_app.command("add")
257
+ def code_add(
258
+ context: typer.Context,
259
+ code: Annotated[str, typer.Argument(help="The digits, e.g. 445566.")],
260
+ kind: Annotated[
261
+ str, typer.Option("--type", help="permanent|temporary|onetime|emergency")
262
+ ] = "permanent",
263
+ minutes: Minutes = 60,
264
+ slot: Annotated[int, typer.Option(help="Slot to write; the keypad picks by default.")] = 0xFF,
265
+ ) -> None:
266
+ """Store a passcode.
267
+
268
+ temporary and onetime take a window of --minutes from now; emergency is a duress
269
+ code that opens the lock and raises the alarm.
270
+ """
271
+ password_type = choice(PASSWORD_TYPES, kind, "--type")
272
+ timed = password_type in (PasswordType.TIME_LIMIT, PasswordType.ONCE)
273
+ window = Window.for_minutes(minutes) if timed else None
274
+
275
+ async def action(keypad: Keypad) -> None:
276
+ assigned = await keypad.add_passcode(code, password_type, window=window, slot=slot)
277
+ detail = f", valid until {when(window.ends_at)}" if window else ""
278
+ kind = name(password_type, PASSWORD_TYPES)
279
+ console.print(f"stored in slot [bold]{assigned}[/bold] as {kind}{detail}")
280
+
281
+ with_keypad(target(context), action)
282
+
283
+
284
+ @code_app.command("delete")
285
+ def code_delete(
286
+ context: typer.Context,
287
+ slots: Annotated[list[int], typer.Argument(help="Slots to erase.")],
288
+ ) -> None:
289
+ """Erase passcode slots, reporting what was in each."""
290
+
291
+ async def action(keypad: Keypad) -> None:
292
+ for slot in slots:
293
+ before = await keypad.read_passcode(slot)
294
+ if before is None:
295
+ console.print(f"slot {slot}: already empty")
296
+ continue
297
+ await keypad.delete_passcode(slot)
298
+ after = await keypad.read_passcode(slot)
299
+ state = "empty" if after is None else f"[red]still holds {after.code}[/red]"
300
+ console.print(f"slot {slot}: erased {before.code} → {state}")
301
+
302
+ with_keypad(target(context), action)
303
+
304
+
305
+ # --- cards ---
306
+
307
+
308
+ @card_app.command("list")
309
+ def card_list(
310
+ context: typer.Context,
311
+ slots: Annotated[int, typer.Option(help="How many slots to walk.")] = 100,
312
+ ) -> None:
313
+ """List every enrolled card. Read-only."""
314
+
315
+ async def action(keypad: Keypad) -> None:
316
+ rows = Rows("slot", "type", "data")
317
+ with progress(f"walking {slots} slots…"):
318
+ async for card in keypad.list_cards(slots):
319
+ label = "reserved, empty" if card.is_placeholder else str(card.card_type)
320
+ rows.add(str(card.slot), label, card.data.hex())
321
+ rows.render() if rows else console.print("no cards enrolled")
322
+
323
+ with_keypad(target(context), action)
324
+
325
+
326
+ @card_app.command("add")
327
+ def card_add(
328
+ context: typer.Context,
329
+ kind: Annotated[
330
+ str, typer.Option("--type", help="permanent|temporary|emergency")
331
+ ] = "permanent",
332
+ minutes: Minutes = 60,
333
+ force: Annotated[bool, typer.Option("--force", help=FORCE_WARNING)] = False,
334
+ wait: Annotated[int, typer.Option(help="Seconds to wait for the card.")] = 30,
335
+ ) -> None:
336
+ """Enrol an NFC card.
337
+
338
+ The keypad takes only SwitchBot's own cards; anything else is refused.
339
+ """
340
+ nfc_type = choice(NFC_TYPES, kind, "--type")
341
+ window = Window.for_minutes(minutes) if nfc_type is NfcType.TIME_LIMIT else None
342
+ if force:
343
+ console.print(f"[bold red]{FORCE_WARNING}[/bold red]")
344
+ typer.confirm("Overwrite the card you are about to present?", abort=True)
345
+
346
+ async def action(keypad: Keypad) -> None:
347
+ def ready(slot: int) -> None:
348
+ console.print(f"reserved slot {slot} — [bold]hold the card to the keypad now[/bold]")
349
+
350
+ enrolment = await keypad.enrol_card(
351
+ nfc_type, window=window, force=force, wait_seconds=wait, on_ready=ready
352
+ )
353
+ console.print(f"card committed to slot [bold]{enrolment.slot}[/bold]")
354
+
355
+ with_keypad(target(context), action)
356
+
357
+
358
+ @card_app.command("delete")
359
+ def card_delete(
360
+ context: typer.Context, slots: Annotated[list[int], typer.Argument(help="Slots to erase.")]
361
+ ) -> None:
362
+ """Erase card slots."""
363
+
364
+ async def action(keypad: Keypad) -> None:
365
+ for slot in slots:
366
+ if await keypad.read_card(slot) is None:
367
+ console.print(f"card slot {slot}: already empty")
368
+ continue
369
+ await keypad.delete_card(slot)
370
+ still = await keypad.read_card(slot)
371
+ console.print(
372
+ f"card slot {slot}: erased → "
373
+ + ("empty" if still is None else "[red]still present[/red]")
374
+ )
375
+
376
+ with_keypad(target(context), action)
377
+
378
+
379
+ # --- fingerprints ---
380
+
381
+
382
+ @finger_app.command("add")
383
+ def finger_add(
384
+ context: typer.Context,
385
+ kind: Annotated[
386
+ str, typer.Option("--type", help="permanent|temporary|emergency")
387
+ ] = "permanent",
388
+ minutes: Minutes = 60,
389
+ enhance: Annotated[
390
+ bool, typer.Option("--enhance", help="Second print for a finger already enrolled.")
391
+ ] = False,
392
+ collect: Annotated[
393
+ int, typer.Option(help="Seconds to leave the keypad alone while it reads.")
394
+ ] = FINGER_COLLECT_SECONDS,
395
+ ) -> None:
396
+ """Enrol a fingerprint -- several presses of the same finger.
397
+
398
+ The tool disconnects while the reader works; the keypad will not scan otherwise.
399
+ """
400
+ finger_type = choice(FINGER_TYPES, kind, "--type")
401
+ window = Window.for_minutes(minutes) if finger_type is FingerType.TIME_LIMIT else None
402
+
403
+ async def action(keypad: Keypad) -> None:
404
+ def ready(slot: int) -> None:
405
+ console.print(
406
+ f"reserved slot {slot} — [bold]press the same finger now[/bold], "
407
+ f"lifting between reads"
408
+ )
409
+ console.print(f" (disconnected on purpose; reading the result in {collect}s)")
410
+
411
+ enrolment = await keypad.enrol_fingerprint(
412
+ finger_type,
413
+ window=window,
414
+ enhance=enhance,
415
+ collect_seconds=collect,
416
+ on_ready=ready,
417
+ )
418
+ console.print(
419
+ f"collected {enrolment.presses} press(es), committed to slot "
420
+ f"[bold]{enrolment.slot}[/bold]"
421
+ )
422
+ if enhance:
423
+ console.print(
424
+ "[yellow]Write this slot down next to the original: an enhanced finger "
425
+ "occupies two slots, deleting it means deleting both, and nothing on the "
426
+ "keypad can pair them for you.[/yellow]"
427
+ )
428
+
429
+ with_keypad(target(context), action)
430
+
431
+
432
+ @finger_app.command("delete")
433
+ def finger_delete(
434
+ context: typer.Context, slots: Annotated[list[int], typer.Argument(help="Slots to erase.")]
435
+ ) -> None:
436
+ """Erase fingerprint slots. A delete cannot be confirmed: the slot query is an echo."""
437
+
438
+ async def action(keypad: Keypad) -> None:
439
+ for slot in slots:
440
+ await keypad.delete_fingerprint(slot)
441
+ console.print(f"fingerprint slot {slot}: delete acknowledged")
442
+
443
+ with_keypad(target(context), action)
444
+
445
+
446
+ # --- settings ---
447
+
448
+
449
+ @settings_app.command("keyboard")
450
+ def settings_keyboard(
451
+ context: typer.Context,
452
+ state: Annotated[str, typer.Argument(help="1|0")],
453
+ ) -> None:
454
+ """Enable or disable the keypad entirely."""
455
+ enabled = switch(state)
456
+ with_keypad(target(context), lambda keypad: keypad.set_keyboard_disabled(not enabled))
457
+ console.print(f"keypad {enabled:d}")
458
+
459
+
460
+ @settings_app.command("alarm")
461
+ def settings_alarm(
462
+ context: typer.Context,
463
+ state: Annotated[str, typer.Argument(help="1|0, or silence")],
464
+ ) -> None:
465
+ """Arm, disarm or silence the tamper alarm."""
466
+ if state == "silence":
467
+ with_keypad(target(context), lambda keypad: keypad.silence_removal_alarm())
468
+ console.print("removal alarm silenced, still armed")
469
+ return
470
+ enabled = switch(state)
471
+ with_keypad(target(context), lambda keypad: keypad.set_removal_alarm(enabled))
472
+ console.print(f"removal alarm {enabled:d}")
473
+
474
+
475
+ @settings_app.command("backlight")
476
+ def settings_backlight(
477
+ context: typer.Context,
478
+ mode: Annotated[str, typer.Argument(help="off|auto|on")],
479
+ level: Annotated[int, typer.Option(help="Brightness, 1-5.")] = 0,
480
+ ) -> None:
481
+ """Set the backlight mode and brightness."""
482
+ backlight = choice(BACKLIGHT_MODES, mode, "mode")
483
+ with_keypad(target(context), lambda keypad: keypad.set_backlight(backlight, level))
484
+ console.print(f"backlight {mode}" + (f", level {level}" if level else ""))
485
+
486
+
487
+ @settings_app.command("sound")
488
+ def settings_sound(
489
+ context: typer.Context, state: Annotated[str, typer.Argument(help="1|0")]
490
+ ) -> None:
491
+ """Turn the keypress beep on or off."""
492
+ enabled = switch(state)
493
+ with_keypad(target(context), lambda keypad: keypad.set_sound(enabled))
494
+ console.print(f"sound {enabled:d}")
495
+
496
+
497
+ @settings_app.command("lock-button")
498
+ def settings_lock_button(
499
+ context: typer.Context,
500
+ state: Annotated[str, typer.Argument(help="on|off|timed")],
501
+ seconds: Annotated[int, typer.Option(help="Window for `timed`, in seconds.")] = 0,
502
+ ) -> None:
503
+ """Enable, disable or time-limit the keypad's lock button."""
504
+ button = choice(LOCK_BUTTON_STATES, state, "state")
505
+ with_keypad(target(context), lambda keypad: keypad.set_lock_button(button, seconds))
506
+ console.print(f"lock button {state}" + (f", window {seconds}s" if seconds else ""))
507
+
508
+
509
+ @settings_app.command("quick-unlock")
510
+ def settings_quick_unlock(
511
+ context: typer.Context, state: Annotated[str, typer.Argument(help="1|0")]
512
+ ) -> None:
513
+ """Open on the code alone, without the confirm key."""
514
+ enabled = switch(state)
515
+ with_keypad(target(context), lambda keypad: keypad.set_quick_unlock(enabled))
516
+ console.print(f"quick unlock {enabled:d}")
517
+
518
+
519
+ @settings_app.command("clear")
520
+ def settings_clear(
521
+ context: typer.Context,
522
+ what: Annotated[str, typer.Argument(help="penalty|alarm")],
523
+ kind: Annotated[str, typer.Argument(help="password|card|finger")],
524
+ ) -> None:
525
+ """Clear a lockout after failed attempts, or acknowledge a duress alarm."""
526
+ credential = choice(CREDENTIAL_KINDS, kind, "kind")
527
+ if what == "penalty":
528
+ with_keypad(target(context), lambda keypad: keypad.clear_time_penalty(credential))
529
+ elif what == "alarm":
530
+ with_keypad(target(context), lambda keypad: keypad.clear_urgent_alarm(credential))
531
+ else:
532
+ raise typer.BadParameter("expected penalty or alarm", param_hint="what")
533
+ console.print(f"{what} cleared for {kind}")
@@ -0,0 +1,318 @@
1
+ """`opensb lockpro` -- lock, unlock, status, settings, calibration and the lock's own log."""
2
+
3
+ import time
4
+ from collections.abc import Awaitable, Callable
5
+ from typing import Annotated
6
+
7
+ import typer
8
+ from opensb.cli.common import Rows, Target, choice, console, day, name, run, switch, target
9
+ from opensb.lockpro import discovery
10
+ from opensb.lockpro.device import Lock
11
+ from opensb.lockpro.enums import KeyAction, KeyTrigger, LatchType, LockAlert
12
+ from opensb.lockpro.models import BatteryBay, TimedSetting
13
+
14
+ KEY_ACTIONS = {"lock": KeyAction.LOCK, "unlock": KeyAction.UNLOCK, "toggle": KeyAction.TOGGLE}
15
+ KEY_TRIGGERS = {"single": KeyTrigger.SINGLE, "double": KeyTrigger.DOUBLE}
16
+ ALERTS = {"door": LockAlert.DOOR_LEFT_OPEN, "unlocked": LockAlert.NOT_LOCKED}
17
+ LATCH_TYPES = {"normal": LatchType.NORMAL, "night": LatchType.NIGHT_LATCH}
18
+
19
+ app = typer.Typer(help=__doc__, no_args_is_help=True)
20
+ # No `no_args_is_help`: a bare `settings` prints what the lock reports.
21
+ settings_app = typer.Typer(help="Lock settings.")
22
+ app.add_typer(settings_app, name="settings")
23
+
24
+
25
+ def with_lock[T](target_: Target, action: Callable[[Lock], Awaitable[T]]) -> T:
26
+ """Connect to the lock `target_` names, run `action`, disconnect."""
27
+
28
+ async def go() -> T:
29
+ async with Lock.over_ble(target_.key(), adapter=target_.adapter) as lock:
30
+ return await action(lock)
31
+
32
+ return run(go())
33
+
34
+
35
+ @app.command()
36
+ def scan(seconds: Annotated[float, typer.Option(help="How long to listen.")] = 10.0) -> None:
37
+ """List Lock Pro devices in range, with what they broadcast."""
38
+ locks = run(discovery.discover(seconds))
39
+ if not locks:
40
+ console.print("no lock seen")
41
+ return
42
+ rows = Rows("address", "name", "rssi", "battery", "status")
43
+ for lock in locks:
44
+ rows.add(
45
+ lock.address,
46
+ lock.name or "",
47
+ str(lock.rssi),
48
+ f"{lock.state.battery}%",
49
+ name(lock.state.status),
50
+ )
51
+ rows.render()
52
+
53
+
54
+ @app.command()
55
+ def status(context: typer.Context) -> None:
56
+ """Where the bolt is, and the battery."""
57
+
58
+ async def action(lock: Lock) -> None:
59
+ info = await lock.info()
60
+ power = await lock.battery()
61
+ rows = Rows("setting", "value", headers=False)
62
+ rows.add("status", name(info.status))
63
+ rows.add("calibrated", f"{info.calibrated:d}")
64
+ rows.add("door open", f"{info.door_open:d}")
65
+ rows.add("door left open alarm", f"{info.door_not_closed_alert:d}")
66
+ rows.add("left unlocked alarm", f"{info.not_locked_alert:d}")
67
+ rows.add("battery", f"{power.battery}%")
68
+ rows.add("firmware", f"{power.firmware}")
69
+ rows.render()
70
+
71
+ with_lock(target(context), action)
72
+
73
+
74
+ @app.command("lock")
75
+ def lock_it(context: typer.Context) -> None:
76
+ """Throw the bolt."""
77
+ info = with_lock(target(context), lambda lock: lock.lock())
78
+ console.print(f"lock\t{name(info.status)}")
79
+
80
+
81
+ @app.command("unlock")
82
+ def unlock_it(
83
+ context: typer.Context,
84
+ latch: Annotated[
85
+ bool, typer.Option("--latch/--no-latch", help="Also pull the latch, opening the door.")
86
+ ] = True,
87
+ ) -> None:
88
+ """Withdraw the bolt."""
89
+ info = with_lock(target(context), lambda lock: lock.unlock(unlatch=latch))
90
+ console.print(f"unlock\t{name(info.status)}")
91
+
92
+
93
+ @app.command("log")
94
+ def read_log(
95
+ context: typer.Context,
96
+ limit: Annotated[int, typer.Option(help="How many entries to read.")] = 20,
97
+ since: Annotated[int | None, typer.Option(help="Entries older than this unix time.")] = None,
98
+ ) -> None:
99
+ """Walk the lock's event log, newest first."""
100
+
101
+ async def action(lock: Lock) -> None:
102
+ grouped = console.is_terminal
103
+ clock, stamp = "%H:%M:%S", "%Y-%m-%d %H:%M:%S"
104
+ rows = Rows("time", "source", "action", "value")
105
+ shown = None
106
+ async for entry in lock.read_log(since, limit):
107
+ if grouped and (heading := day(entry.at)) != shown:
108
+ rows.section()
109
+ rows.add(f"[bold]{heading}[/bold]", "")
110
+ shown = heading
111
+ rows.add(
112
+ time.strftime(clock if grouped else stamp, time.localtime(entry.at)),
113
+ name(entry.source),
114
+ str(entry.action),
115
+ str(entry.value),
116
+ )
117
+ rows.render() if rows else console.print("the log is empty")
118
+
119
+ with_lock(target(context), action)
120
+
121
+
122
+ @app.command("emergency")
123
+ def emergency(
124
+ context: typer.Context,
125
+ reverse: Annotated[
126
+ bool, typer.Option("--reverse", help="Turn the other way, if the bolt went wrong.")
127
+ ] = False,
128
+ ) -> None:
129
+ """Drive the motor until it stalls. Clears the calibration.
130
+
131
+ The only control that works on a jammed lock. Afterwards the lock does not know
132
+ its travel, so run `calibrate`, and do not trust the status it reports until you
133
+ have.
134
+ """
135
+ with_lock(target(context), lambda lock: lock.emergency_unlock(reverse))
136
+ console.print("motor driven to a stall; the lock is now uncalibrated -- run `calibrate`")
137
+
138
+
139
+ @app.command("calibrate")
140
+ def calibrate(
141
+ context: typer.Context,
142
+ latch: Annotated[
143
+ str | None,
144
+ typer.Option(help="normal|night. Only send this if the lock reported the type."),
145
+ ] = None,
146
+ ) -> None:
147
+ """Teach the lock its travel again, step by step.
148
+
149
+ Every step runs on one connection. Sent separately they are all accepted while
150
+ nothing is recorded, and the lock stays uncalibrated.
151
+ """
152
+ latch_type = choice(LATCH_TYPES, latch, "latch") if latch is not None else None
153
+
154
+ def step(message: str) -> None:
155
+ console.print(message)
156
+ typer.confirm("done?", abort=True)
157
+
158
+ async def action(lock: Lock) -> None:
159
+ await lock.enter_calibration()
160
+ step("the motor is released -- turn the knob to LOCKED, with the door shut")
161
+ await lock.set_lock_position()
162
+ step("now turn the knob to UNLOCKED")
163
+ await lock.set_unlock_position()
164
+ step("now open the door")
165
+ await lock.set_door_open_position()
166
+ if latch_type is not None:
167
+ await lock.set_latch_type(latch_type)
168
+ console.print("testing the unlock position")
169
+ await lock.test_unlock()
170
+ console.print("testing the lock position")
171
+ await lock.test_lock()
172
+ await lock.finish_calibration()
173
+ info = await lock.info()
174
+ console.print(f"calibrated\t{info.calibrated:d}")
175
+
176
+ with_lock(target(context), action)
177
+
178
+
179
+ @settings_app.callback(invoke_without_command=True)
180
+ def settings_show(context: typer.Context) -> None:
181
+ """Everything the lock reports about how it is configured."""
182
+ if context.invoked_subcommand is not None:
183
+ return
184
+
185
+ async def action(lock: Lock) -> None:
186
+ current = await lock.settings()
187
+ rows = Rows("setting", "value", headers=False)
188
+ rows.add("light", f"{current.flags.light:d}")
189
+ rows.add("sound", f"{current.flags.sound:d}")
190
+ rows.add("manual unlock linkage", f"{current.flags.linkage:d}")
191
+ rows.add("lock go", f"{current.flags.lock_go:d}")
192
+ rows.add("button", f"{current.flags.key_enabled:d}")
193
+ rows.add("button press", name(current.flags.key_trigger, KEY_TRIGGERS))
194
+ rows.add("button action", name(current.flags.key_action, KEY_ACTIONS))
195
+ rows.add("settings byte", f"0x{current.flags.raw:02x}")
196
+ rows.add("auto lock", _timed(current.auto_lock))
197
+ rows.add("auto lock paused", f"{current.auto_lock_paused:d}")
198
+ rows.add("force lock", _timed(current.force_lock))
199
+ rows.add("latch keep time", f"{current.latch_keep_time}s")
200
+ rows.add("door left open alarm", _timed(current.door_left_open_alert))
201
+ rows.add("left unlocked alarm", _timed(current.not_locked_alert))
202
+ rows.add("left battery", _bay(current.battery_bays.left))
203
+ rows.add("right battery", _bay(current.battery_bays.right))
204
+ rows.render()
205
+
206
+ with_lock(target(context), action)
207
+
208
+
209
+ def _timed(setting: TimedSetting) -> str:
210
+ """A timed setting as the tool takes it back: the flag, then its delay."""
211
+ return f"{setting.enabled:d}" + (f", {setting.seconds}s" if setting.enabled else "")
212
+
213
+
214
+ def _bay(bay: BatteryBay) -> str:
215
+ return f"{bay.battery}%" if bay.inserted else "empty"
216
+
217
+
218
+ @settings_app.command("light")
219
+ def settings_light(
220
+ context: typer.Context, state: Annotated[str, typer.Argument(help="1|0")]
221
+ ) -> None:
222
+ """The indicator light on the lock body."""
223
+ enabled = switch(state)
224
+ with_lock(target(context), lambda lock: lock.set_light(enabled))
225
+ console.print(f"light {enabled:d}")
226
+
227
+
228
+ @settings_app.command("sound")
229
+ def settings_sound(
230
+ context: typer.Context, state: Annotated[str, typer.Argument(help="1|0")]
231
+ ) -> None:
232
+ """Beeps and voice prompts."""
233
+ enabled = switch(state)
234
+ with_lock(target(context), lambda lock: lock.set_sound(enabled))
235
+ console.print(f"sound {enabled:d}")
236
+
237
+
238
+ @settings_app.command("linkage")
239
+ def settings_linkage(
240
+ context: typer.Context, state: Annotated[str, typer.Argument(help="1|0")]
241
+ ) -> None:
242
+ """Report a by-hand unlock to whatever is linked to the lock."""
243
+ enabled = switch(state)
244
+ with_lock(target(context), lambda lock: lock.set_manual_unlock_linkage(enabled))
245
+ console.print(f"manual unlock linkage {enabled:d}")
246
+
247
+
248
+ @settings_app.command("button")
249
+ def settings_button(
250
+ context: typer.Context,
251
+ state: Annotated[str, typer.Argument(help="1|0")],
252
+ action: Annotated[str, typer.Option(help="lock|unlock|toggle")] = "toggle",
253
+ press: Annotated[str, typer.Option(help="single|double")] = "single",
254
+ ) -> None:
255
+ """What the lock's own button does. Switching it off keeps the rest."""
256
+ enabled = switch(state)
257
+ what = choice(KEY_ACTIONS, action, "action")
258
+ how = choice(KEY_TRIGGERS, press, "press")
259
+ with_lock(target(context), lambda lock: lock.set_key(enabled, what, how))
260
+ console.print(f"button {enabled:d}" + (f", {press} press, {action}" if enabled else ""))
261
+
262
+
263
+ @settings_app.command("auto-lock")
264
+ def settings_auto_lock(
265
+ context: typer.Context,
266
+ state: Annotated[str, typer.Argument(help="1|0")],
267
+ seconds: Annotated[int, typer.Option(help="Delay before it locks itself.")] = 0,
268
+ ) -> None:
269
+ """Lock again by itself after a delay."""
270
+ enabled = switch(state)
271
+ with_lock(target(context), lambda lock: lock.set_auto_lock(enabled, seconds))
272
+ console.print(f"auto lock {enabled:d}" + (f", {seconds}s" if enabled else ""))
273
+
274
+
275
+ @settings_app.command("force-lock")
276
+ def settings_force_lock(
277
+ context: typer.Context,
278
+ state: Annotated[str, typer.Argument(help="1|0")],
279
+ seconds: Annotated[int, typer.Option(help="Delay before it locks itself.")] = 0,
280
+ ) -> None:
281
+ """Lock even when the door is reported open."""
282
+ enabled = switch(state)
283
+ with_lock(target(context), lambda lock: lock.set_force_lock(enabled, seconds))
284
+ console.print(f"force lock {enabled:d}" + (f", {seconds}s" if enabled else ""))
285
+
286
+
287
+ @settings_app.command("pause")
288
+ def settings_pause(
289
+ context: typer.Context, state: Annotated[str, typer.Argument(help="1|0")]
290
+ ) -> None:
291
+ """Suspend auto-lock without making it forget its delay."""
292
+ paused = switch(state)
293
+ with_lock(target(context), lambda lock: lock.set_auto_lock_paused(paused))
294
+ console.print(f"auto lock paused {paused:d}")
295
+
296
+
297
+ @settings_app.command("latch")
298
+ def settings_latch(
299
+ context: typer.Context,
300
+ seconds: Annotated[int, typer.Argument(help="Seconds to hold the latch back.")],
301
+ ) -> None:
302
+ """How long the latch stays retracted before springing back."""
303
+ with_lock(target(context), lambda lock: lock.set_latch_keep_time(seconds))
304
+ console.print(f"latch keep time {seconds}s")
305
+
306
+
307
+ @settings_app.command("alarm")
308
+ def settings_alarm(
309
+ context: typer.Context,
310
+ kind: Annotated[str, typer.Argument(help="door|unlocked")],
311
+ state: Annotated[str, typer.Argument(help="1|0")],
312
+ seconds: Annotated[int, typer.Option(help="Delay before it sounds.")] = 0,
313
+ ) -> None:
314
+ """Arm the door-left-open or left-unlocked alarm, and set its delay."""
315
+ alert = choice(ALERTS, kind, "kind")
316
+ enabled = switch(state)
317
+ with_lock(target(context), lambda lock: lock.set_alert(alert, enabled, seconds))
318
+ console.print(f"{kind} alarm {enabled:d}" + (f", {seconds}s" if enabled else ""))
@@ -0,0 +1,57 @@
1
+ """`opensb` -- drive SwitchBot devices from the shell, over BLE and nothing else."""
2
+
3
+ from importlib import import_module
4
+
5
+ import typer
6
+ from opensb.cli.common import Adapter, KeyFile, Mac, Target, console, target
7
+
8
+ # Each device is its own distribution, so a group appears only when its package is
9
+ # installed: `pip install opensb[keypad,lockpro]`.
10
+ DEVICE_GROUPS = {
11
+ "keypad": "opensb.cli.keypad",
12
+ "lockpro": "opensb.cli.lockpro",
13
+ }
14
+
15
+ app = typer.Typer(help=__doc__, no_args_is_help=True, add_completion=False, rich_markup_mode="rich")
16
+
17
+ installed = []
18
+ for group, module in DEVICE_GROUPS.items():
19
+ try:
20
+ app.add_typer(import_module(module).app, name=group)
21
+ except ImportError:
22
+ continue
23
+ installed.append(group)
24
+
25
+
26
+ @app.callback()
27
+ def main_options(
28
+ context: typer.Context,
29
+ key_file: KeyFile = None,
30
+ mac: Mac = None,
31
+ adapter: Adapter = None,
32
+ ) -> None:
33
+ context.obj = Target(key_file=key_file, mac=mac, adapter=adapter)
34
+
35
+
36
+ @app.command("key")
37
+ def key_show(context: typer.Context) -> None:
38
+ """Show which device the key points at, without revealing it."""
39
+ key = target(context).key()
40
+ console.print(f"{key.mac} key_id {key.key_id:#04x} key {len(key.key)} bytes")
41
+
42
+
43
+ @app.command("devices")
44
+ def devices() -> None:
45
+ """List the device packages this install can talk to."""
46
+ for group in DEVICE_GROUPS:
47
+ console.print(f"{group}\t{'installed' if group in installed else '-'}")
48
+ if not installed:
49
+ # markup off: rich would read the extras list as a style tag and eat it.
50
+ console.print(
51
+ "\nNo device package installed. Add one: pip install 'opensb[keypad,lockpro]'",
52
+ markup=False,
53
+ )
54
+
55
+
56
+ def main() -> None:
57
+ app()
File without changes