entr-ble-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ def main() -> None:
2
+ from .cli import main as cli_main
3
+
4
+ cli_main()
5
+
6
+
7
+ __all__ = ["main"]
entr_ble_cli/cli.py ADDED
@@ -0,0 +1,132 @@
1
+ import argparse
2
+ import asyncio
3
+ import logging
4
+
5
+ from bleak.exc import (
6
+ BleakBluetoothNotAvailableError,
7
+ BleakBluetoothNotAvailableReason,
8
+ BleakDeviceNotFoundError,
9
+ BleakError,
10
+ )
11
+
12
+ from entr_ble import EntrLockError, EntrProtocolError
13
+
14
+ from .commands import (
15
+ access,
16
+ config,
17
+ discovery,
18
+ maintenance,
19
+ settings,
20
+ setup,
21
+ status,
22
+ users,
23
+ )
24
+ from .commands.common import handle
25
+ from .shared import CommandError
26
+
27
+
28
+ class HelpFormatter(argparse.RawDescriptionHelpFormatter):
29
+ def __init__(self, prog, groups):
30
+ super().__init__(prog, max_help_position=32)
31
+ self.groups = groups
32
+
33
+ def _format_action(self, action):
34
+ if not isinstance(action, argparse._SubParsersAction):
35
+ return super()._format_action(action)
36
+ commands = {command.dest: command for command in action._get_subactions()}
37
+ parts = []
38
+ for title, names in self.groups:
39
+ parts.append(f"{' ' * self._current_indent}{title}:\n")
40
+ self._indent()
41
+ for name in names:
42
+ parts.append(super()._format_action(commands[name]))
43
+ self._dedent()
44
+ parts.append("\n")
45
+ return "".join(parts)
46
+
47
+
48
+ def main() -> None:
49
+ groups = []
50
+ parser = argparse.ArgumentParser(
51
+ prog="entr-ble-cli",
52
+ description="Set up and control an ENTR Bluetooth lock.",
53
+ usage="%(prog)s [options] COMMAND ...",
54
+ formatter_class=lambda prog: HelpFormatter(prog, groups),
55
+ epilog=(
56
+ "Getting started:\n"
57
+ " 1. Find your lock: entr-ble-cli scan\n"
58
+ " 2. Set up access with set-owner (new lock), enroll (replace the owner),\n"
59
+ " or activate (use a key supplied by the owner).\n"
60
+ " 3. Read its status: entr-ble-cli status ADDRESS\n\n"
61
+ "Replace ADDRESS with the Bluetooth address printed by scan.\n"
62
+ "For a command's arguments and options, run:\n"
63
+ " entr-ble-cli COMMAND --help\n\n"
64
+ "Example: entr-ble-cli unlock AA:BB:CC:DD:EE:FF"
65
+ ),
66
+ )
67
+ parser.add_argument(
68
+ "-q", "--quiet", action="store_true", help="hide progress messages"
69
+ )
70
+ sub = parser.add_subparsers(dest="command", title="commands", metavar="COMMAND")
71
+ for title, modules in (
72
+ ("Discovery and setup", (discovery, setup)),
73
+ ("Lock control", (access,)),
74
+ ("Status and information", (status,)),
75
+ ("Users and keys", (users,)),
76
+ ("Settings", (settings,)),
77
+ ("Maintenance and logs", (maintenance,)),
78
+ ("Home Assistant", (config,)),
79
+ ):
80
+ existing = set(sub.choices)
81
+ for module in modules:
82
+ module.register(sub)
83
+ groups.append((title, [name for name in sub.choices if name not in existing]))
84
+ for command in sub.choices.values():
85
+ command.add_argument(
86
+ "-q",
87
+ "--quiet",
88
+ action="store_true",
89
+ default=argparse.SUPPRESS,
90
+ help="hide progress messages",
91
+ )
92
+ args = parser.parse_args()
93
+ if args.command is None:
94
+ parser.print_help()
95
+ return
96
+ logger = logging.getLogger("entr_ble_cli")
97
+ logger.handlers = [logging.StreamHandler()]
98
+ logger.setLevel(logging.WARNING if args.quiet else logging.INFO)
99
+ logger.propagate = False
100
+ try:
101
+ if args.command == "export-homeassistant":
102
+ config.run(args)
103
+ else:
104
+ asyncio.run(handle(args))
105
+ except (CommandError, EntrProtocolError, BleakError, OSError, ValueError) as exc:
106
+ parser.exit(1, f"Error: {error_message(exc)}\n")
107
+ except KeyboardInterrupt, EOFError:
108
+ parser.exit(130, "Aborted.\n")
109
+
110
+
111
+ def error_message(exc):
112
+ if isinstance(exc, BleakBluetoothNotAvailableError):
113
+ if exc.reason == BleakBluetoothNotAvailableReason.POWERED_OFF:
114
+ return "Bluetooth is turned off. Turn it on and try again."
115
+ if exc.reason.name.startswith("DENIED_"):
116
+ return "Bluetooth access was denied. Allow this application to use Bluetooth and try again."
117
+ return "Bluetooth is unavailable. Check that a Bluetooth adapter is connected and enabled."
118
+ if isinstance(exc, BleakDeviceNotFoundError):
119
+ return "The lock could not be found. Move closer and run 'entr-ble-cli scan' to check its address."
120
+ if isinstance(exc, TimeoutError):
121
+ return "Bluetooth communication timed out. Move closer to the lock and check that Bluetooth is enabled."
122
+ if isinstance(exc, BleakError):
123
+ return "Bluetooth communication failed. Check that Bluetooth is enabled and the lock is nearby, then try again."
124
+ if isinstance(exc, EntrLockError):
125
+ return "The lock refused the command. Check your password, key permissions, and whether your lock supports this command."
126
+ if isinstance(exc, EntrProtocolError):
127
+ return "The lock sent an unexpected response. Check the connection and whether your lock supports this command."
128
+ if isinstance(exc, OSError):
129
+ if exc.filename:
130
+ return f"Could not access {exc.filename}: {exc.strerror or 'check the file and its permissions'}."
131
+ return "Could not communicate over Bluetooth. Check your adapter and Bluetooth service, then try again."
132
+ return str(exc)
File without changes
@@ -0,0 +1,16 @@
1
+ def register(sub):
2
+ p = sub.add_parser("unlock", help="unlock the door")
3
+ p.add_argument("address")
4
+ p.set_defaults(run=run)
5
+
6
+ p = sub.add_parser("lock", help="lock the door")
7
+ p.add_argument("address")
8
+ p.set_defaults(run=run)
9
+
10
+
11
+ async def run(client, creds, args) -> list[str]:
12
+ app_id = bytes.fromhex(creds.app_id)
13
+ await getattr(client, args.command)(
14
+ bytes.fromhex(creds.user_id), app_id, bytes.fromhex(creds.ble_ekey)
15
+ )
16
+ return [f"{args.command} sent"]
@@ -0,0 +1,78 @@
1
+ import argparse
2
+ import getpass
3
+ import logging
4
+ import warnings
5
+
6
+ from bleak.exc import BleakError
7
+
8
+ from entr_ble.client import EntrLockClient
9
+
10
+ from ..shared import CommandError
11
+ from ..store import get as get_credentials
12
+ from .discovery import scan
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ async def handle(args: argparse.Namespace) -> None:
18
+ if args.command == "scan":
19
+ logger.info("Looking for nearby locks...")
20
+ lines = await scan(args.timeout, args.all)
21
+ else:
22
+ if args.command == "factory-reset" and not args.yes:
23
+ answer = input(
24
+ f"wipe all users and settings on {args.address}? type 'yes' to confirm: "
25
+ )
26
+ if answer.strip().lower() != "yes":
27
+ raise CommandError("aborted")
28
+ credentials = get_credentials(args.address)
29
+ if credentials is None and not getattr(args, "setup", False):
30
+ raise CommandError(
31
+ f"No saved key for {args.address}. Run 'entr-ble-cli activate --help' to use an existing key, or 'entr-ble-cli enroll --help' to become the owner."
32
+ )
33
+ if hasattr(args, "admin_code"):
34
+ if args.admin_code is None:
35
+ args.admin_code = read_password(args.password_prompt, "-p / --password")
36
+ if not args.admin_code:
37
+ args.admin_code = getattr(args, "password_default", None)
38
+ if not args.admin_code:
39
+ raise CommandError("The admin password cannot be empty.")
40
+ if hasattr(args, "new_code"):
41
+ if args.new_code is None:
42
+ args.new_code = read_password("New admin password: ", "--new-password")
43
+ if not args.new_code:
44
+ raise CommandError("The new admin password cannot be empty.")
45
+ client = EntrLockClient(args.address)
46
+ try:
47
+ logger.info("Connecting to %s...", args.address)
48
+ await client.connect()
49
+ logger.info("Checking the lock...")
50
+ await client.fetch_comm_version()
51
+ if credentials is not None:
52
+ logger.info("Using your saved key...")
53
+ await client.kdf_resync(
54
+ credentials.kdf_id,
55
+ credentials.role,
56
+ bytes.fromhex(credentials.aes_key),
57
+ )
58
+ logger.info("Sending the command...")
59
+ lines = await args.run(client, credentials, args)
60
+ finally:
61
+ logger.info("Closing the connection...")
62
+ try:
63
+ await client.disconnect()
64
+ except BleakError, OSError:
65
+ logger.warning("Could not close the Bluetooth connection cleanly.")
66
+ for line in lines:
67
+ print(line)
68
+
69
+
70
+ def read_password(prompt, option):
71
+ try:
72
+ with warnings.catch_warnings():
73
+ warnings.simplefilter("error", getpass.GetPassWarning)
74
+ return getpass.getpass(prompt)
75
+ except getpass.GetPassWarning, EOFError:
76
+ raise CommandError(
77
+ f"Could not read a hidden password. Run in a terminal or supply it with {option}."
78
+ ) from None
@@ -0,0 +1,28 @@
1
+ import json
2
+ import logging
3
+ from dataclasses import asdict
4
+
5
+ from ..shared import CommandError
6
+ from ..store import get as get_credentials
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def register(sub):
12
+ p = sub.add_parser(
13
+ "export-homeassistant",
14
+ help="export saved credentials for Home Assistant",
15
+ description="Print the saved credentials as JSON without connecting to the lock. In the ENTR BLE integration setup, choose 'Import existing credentials', enter the same Bluetooth address, and paste this JSON into the credentials field.",
16
+ epilog="The output contains keys that grant access to the lock. Keep it private.",
17
+ )
18
+ p.add_argument("address", help="Bluetooth address of the lock with a saved key")
19
+
20
+
21
+ def run(args):
22
+ logger.info("Reading the saved configuration...")
23
+ credentials = get_credentials(args.address)
24
+ if credentials is None:
25
+ raise CommandError(
26
+ f"No saved key for {args.address}. Set up access with set-owner, enroll, or activate before exporting."
27
+ )
28
+ print(json.dumps(asdict(credentials), indent=2))
@@ -0,0 +1,43 @@
1
+ from bleak import BleakScanner
2
+
3
+ from entr_ble import advertising
4
+
5
+
6
+ def register(sub):
7
+ p = sub.add_parser("scan", help="find nearby ENTR locks")
8
+ p.add_argument("--all", action="store_true", help="also list non-ENTR BLE devices")
9
+ p.add_argument(
10
+ "--timeout",
11
+ type=float,
12
+ default=5.0,
13
+ help="scan duration in seconds (default: 5)",
14
+ )
15
+
16
+
17
+ async def scan(timeout: float = 5.0, show_all: bool = False) -> list[str]:
18
+ found = await BleakScanner.discover(timeout=timeout, return_adv=True)
19
+ locks = []
20
+ others = []
21
+ for address, (device, adv) in found.items():
22
+ lock = advertising.parse_advertisement(address, adv)
23
+ if lock is None:
24
+ others.append((address, f"{address} {device.name or '?'}"))
25
+ else:
26
+ locks.append(
27
+ (
28
+ lock.rssi,
29
+ f"{address} ENTR name={lock.name} state={lock.state_name} rssi={lock.rssi}",
30
+ )
31
+ )
32
+ locks.sort(key=lambda item: -(item[0] if item[0] is not None else -1000))
33
+ others.sort()
34
+ lock_lines = [label for _, label in locks]
35
+ other_lines = [label for _, label in others]
36
+ lines = lock_lines or ["no ENTR lock found"]
37
+ if show_all:
38
+ lines.extend(other_lines)
39
+ elif other_lines:
40
+ lines.append(
41
+ f"({len(other_lines)} other BLE devices hidden, use --all to show them)"
42
+ )
43
+ return lines
@@ -0,0 +1,112 @@
1
+ from entr_ble import const
2
+
3
+ from ..shared import CommandError, add_password_argument
4
+ from ..store import remove as remove_credentials
5
+
6
+
7
+ def register(sub):
8
+ p = sub.add_parser("calibrate", help="calibrate the lock")
9
+ p.add_argument("address")
10
+ add_password_argument(p)
11
+ p.add_argument(
12
+ "--door",
13
+ choices=["left", "right"],
14
+ default="left",
15
+ help="door opening direction (default: left)",
16
+ )
17
+ p.add_argument(
18
+ "--type",
19
+ choices=["normal", "lift"],
20
+ default="normal",
21
+ help="lock type (default: normal)",
22
+ )
23
+ p.set_defaults(run=run)
24
+
25
+ p = sub.add_parser("magnet-calibrate", help="calibrate the door magnet")
26
+ p.add_argument("address")
27
+ add_password_argument(p)
28
+ p.set_defaults(run=run)
29
+
30
+ p = sub.add_parser("set-time", help="set the lock clock")
31
+ p.add_argument("address")
32
+ p.set_defaults(run=run)
33
+
34
+ p = sub.add_parser("audit-trail", help="show the event log")
35
+ p.add_argument("address")
36
+ add_password_argument(
37
+ p, "Admin password (Enter for factory audit password Aa1111): "
38
+ )
39
+ p.set_defaults(password_default=const.DEFAULT_AUDIT_PASSWORD)
40
+ p.set_defaults(run=run)
41
+
42
+ p = sub.add_parser("get-errors", help="show the error log")
43
+ p.add_argument("address")
44
+ p.add_argument(
45
+ "--query",
46
+ default="00" * 8,
47
+ help="8-byte query in hexadecimal (default: zeros)",
48
+ )
49
+ p.add_argument(
50
+ "--raw", action="store_true", help="also print the undecoded response bytes"
51
+ )
52
+ p.set_defaults(run=run)
53
+
54
+ p = sub.add_parser("factory-reset", help="erase all users and settings")
55
+ p.add_argument("address")
56
+ add_password_argument(p)
57
+ p.add_argument("--yes", action="store_true", help="skip the confirmation prompt")
58
+ p.set_defaults(run=run)
59
+
60
+
61
+ async def run(client, creds, args) -> list[str]:
62
+ app_id = bytes.fromhex(creds.app_id)
63
+ if args.command == "calibrate":
64
+ door = args.door
65
+ lock_type = args.type
66
+ await client.calibrate(
67
+ app_id,
68
+ args.admin_code,
69
+ {"left": 1, "right": 3}[door],
70
+ {"normal": 0, "lift": 2}[lock_type],
71
+ )
72
+ return [
73
+ f"calibration done (door {door}, lock type {lock_type})",
74
+ "now run magnet-calibrate with the door magnet in place",
75
+ ]
76
+ if args.command == "magnet-calibrate":
77
+ await client.magnet_calibrate(app_id, args.admin_code)
78
+ return ["magnet calibration done"]
79
+ if args.command == "factory-reset":
80
+ await client.factory_reset(app_id, args.admin_code)
81
+ remove_credentials(creds.address)
82
+ return ["factory reset done, local credentials removed"]
83
+ if args.command == "set-time":
84
+ await client.update_time(app_id)
85
+ return ["lock time set to current UTC time"]
86
+ if args.command == "audit-trail":
87
+ code = args.admin_code
88
+ status = await client.audit_trail_status(code, app_id)
89
+ records = await client.audit_trail_records(code, app_id)
90
+ return [f"records in log: {status['records_count']}"] + [
91
+ f"{r.get('date', '?')} {r.get('event', '?'):<18} {r.get('user', '?') or '-'}"
92
+ for r in records
93
+ ]
94
+ if args.command == "get-errors":
95
+ try:
96
+ query = bytes.fromhex(args.query)
97
+ except ValueError as exc:
98
+ raise CommandError("--query must be hex, e.g. 0000000000000000") from exc
99
+ result = await client.get_errors(query)
100
+ lines = []
101
+ if result["empty"]:
102
+ lines.append(f"no errors logged ({result['length']} bytes, all zero)")
103
+ if not result["empty"] or args.raw:
104
+ data = bytes.fromhex(result["data"])
105
+ lines.extend(
106
+ f"{offset:04x} {data[offset : offset + 8].hex(' ')}"
107
+ for offset in range(0, len(data), 8)
108
+ )
109
+ if args.raw:
110
+ lines.append(f"raw: {result['raw']}")
111
+ return lines
112
+ raise ValueError(args.command)
@@ -0,0 +1,103 @@
1
+ from entr_ble.client import EntrLockClient, build_lock_name, settings_status_byte
2
+
3
+ from ..shared import VOLUME_CHOICES, CommandError, add_password_argument, require_admin
4
+ from ..store import LockCredentials
5
+ from ..store import put as put_credentials
6
+
7
+
8
+ def register(sub):
9
+ p = sub.add_parser("settings", help="change lock settings")
10
+ p.add_argument("address")
11
+ add_password_argument(p)
12
+ p.add_argument(
13
+ "--volume",
14
+ choices=VOLUME_CHOICES,
15
+ help="sound volume",
16
+ )
17
+ p.add_argument("--auto-lock", choices=["on", "off"])
18
+ p.add_argument(
19
+ "--name",
20
+ help="lock name, if not saved locally",
21
+ )
22
+ p.set_defaults(run=run)
23
+
24
+ p = sub.add_parser("change-admin-code", help="change the lock's admin password")
25
+ p.add_argument("address")
26
+ add_password_argument(p, "Current admin password: ")
27
+ p.add_argument(
28
+ "--new-password",
29
+ dest="new_code",
30
+ metavar="PASSWORD",
31
+ help="new admin password, 6 characters (asked securely if omitted)",
32
+ )
33
+ p.add_argument(
34
+ "--name",
35
+ help="lock name, if not saved locally",
36
+ )
37
+ p.set_defaults(run=run)
38
+
39
+
40
+ async def run(client, creds, args) -> list[str]:
41
+ require_admin(creds)
42
+ if args.command == "change-admin-code":
43
+ await _send_device_config(
44
+ client, creds, args.admin_code, args.new_code, None, None, args.name
45
+ )
46
+ return ["admin code changed"]
47
+ auto_lock = {"on": True, "off": False}.get(args.auto_lock or "")
48
+ volume = VOLUME_CHOICES.get(args.volume or "")
49
+ await _send_device_config(
50
+ client,
51
+ creds,
52
+ args.admin_code,
53
+ args.admin_code,
54
+ auto_lock,
55
+ volume,
56
+ args.name,
57
+ )
58
+ changes = [
59
+ label
60
+ for flag, label in (
61
+ (auto_lock is not None, f"auto-lock {args.auto_lock}"),
62
+ (volume is not None, f"volume {args.volume}"),
63
+ (bool(args.name), f"lock name {args.name}"),
64
+ )
65
+ if flag
66
+ ]
67
+ return [
68
+ "settings updated"
69
+ + (f": {', '.join(changes)}" if changes else " (no change requested)")
70
+ ]
71
+
72
+
73
+ async def _send_device_config(
74
+ client: EntrLockClient,
75
+ creds: LockCredentials,
76
+ prev_code: str,
77
+ new_code: str,
78
+ auto_lock: bool | None,
79
+ volume: int | None,
80
+ requested_name: str | None,
81
+ ) -> None:
82
+ config = await client.get_device_config()
83
+ niz_statuses = None
84
+ if "wall_reader_status" in config and "integration_unit_status" in config:
85
+ niz_statuses = bytes(
86
+ [config["wall_reader_status"], config["integration_unit_status"]]
87
+ )
88
+ name = requested_name or creds.lock_name
89
+ if not name:
90
+ raise CommandError(
91
+ "the lock name is unknown, pass --name once (it is stored afterwards)"
92
+ )
93
+ await client.set_device_config(
94
+ bytes.fromhex(creds.app_id),
95
+ prev_code,
96
+ new_code,
97
+ settings_status_byte(client.status_raw or 0, auto_lock, volume),
98
+ build_lock_name(name),
99
+ niz_statuses=niz_statuses,
100
+ )
101
+ if creds.lock_name != name:
102
+ creds.lock_name = name
103
+ put_credentials(creds)
@@ -0,0 +1,110 @@
1
+ import logging
2
+ import os
3
+
4
+ from entr_ble import const
5
+ from entr_ble.client import EntrLockError, build_lock_name, user_id_bytes
6
+
7
+ from ..shared import CommandError, add_password_argument
8
+ from ..store import LockCredentials
9
+ from ..store import put as put_credentials
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def register(sub):
15
+ p = sub.add_parser("set-owner", help="set up a new lock")
16
+ p.add_argument("address")
17
+ add_password_argument(p, "New owner password: ")
18
+ p.add_argument(
19
+ "--name",
20
+ required=True,
21
+ help="lock name (up to 12 bytes)",
22
+ )
23
+ p.add_argument(
24
+ "--user",
25
+ default="owner",
26
+ help="owner name (default: owner)",
27
+ )
28
+ p.add_argument(
29
+ "--provider",
30
+ type=int,
31
+ default=4,
32
+ help="brand id: Mul-T-Lock 1, Yale 2, Nemef 3, Vachette 4, Tesa 5, ASSA 6 (default: 4)",
33
+ )
34
+ p.add_argument(
35
+ "--sync-time",
36
+ action="store_true",
37
+ help="also set the lock clock",
38
+ )
39
+ p.set_defaults(run=run, setup=True)
40
+
41
+ p = sub.add_parser(
42
+ "enroll",
43
+ help="recover owner access, replacing the current owner's key",
44
+ )
45
+ p.add_argument("address")
46
+ add_password_argument(p, "Owner password: ")
47
+ p.add_argument("--name", help="lock name, stored for later settings commands")
48
+ p.set_defaults(run=run, setup=True)
49
+
50
+ p = sub.add_parser("activate", help="redeem a supplied key")
51
+ p.add_argument("address")
52
+ p.add_argument("key_code", help="6-character key code")
53
+ p.set_defaults(run=run, setup=True)
54
+
55
+
56
+ async def run(client, credentials, args) -> list[str]:
57
+ if credentials is not None:
58
+ raise CommandError("this lock already has local credentials")
59
+ app_id = os.urandom(16)
60
+ logger.info("Setting up access to the lock...")
61
+ await client.pair()
62
+ await client.handshake(app_id)
63
+ role = const.ROLE_OWNER
64
+ lock_name = getattr(args, "name", None)
65
+ if args.command == "set-owner":
66
+ user_id = user_id_bytes(args.user)
67
+ result = await client.set_owner(
68
+ args.admin_code,
69
+ app_id,
70
+ user_id,
71
+ build_lock_name(args.name),
72
+ args.provider,
73
+ )
74
+ prefix = f"owner claimed: kdf_id={result['kdf_id']}"
75
+ elif args.command == "enroll":
76
+ result = await client.recover_owner(args.admin_code, app_id)
77
+ user_id = result["user_id"]
78
+ prefix = f"recovered owner: user_id={user_id.hex()} kdf_id={result['kdf_id']}"
79
+ else:
80
+ result = await client.get_new_key(args.key_code, app_id)
81
+ user_id = result["user_id"]
82
+ role = result["role"]
83
+ prefix = f"key activated: user_id={user_id.hex()} role={const.ROLE_NAMES.get(role, role)} kdf_id={result['kdf_id']}"
84
+ if client.session is None:
85
+ raise CommandError("pairing did not create an encrypted session")
86
+ credentials = LockCredentials(
87
+ address=args.address,
88
+ app_id=app_id.hex(),
89
+ user_id=user_id.hex(),
90
+ ble_ekey=result["ble_ekey"].hex(),
91
+ kdf_id=result["kdf_id"],
92
+ aes_key=client.session.key.hex(),
93
+ comm_version=client.comm_version or "",
94
+ role=role,
95
+ lock_name=lock_name,
96
+ )
97
+ logger.info("Saving your key...")
98
+ put_credentials(credentials)
99
+ lines = [prefix, f"credentials saved for {args.address}"]
100
+ if args.command == "set-owner":
101
+ if args.sync_time:
102
+ try:
103
+ await client.update_time(app_id)
104
+ lines.append("lock time set")
105
+ except EntrLockError as exc:
106
+ lines.append(f"warning: could not set the lock clock ({exc})")
107
+ lines.append(
108
+ "if the lock is uncalibrated, run 'calibrate' then 'magnet-calibrate'"
109
+ )
110
+ return lines
@@ -0,0 +1,38 @@
1
+ def register(sub):
2
+ p = sub.add_parser("status", help="show lock, door and battery status")
3
+ p.add_argument("address")
4
+ p.add_argument(
5
+ "--raw", action="store_true", help="also print the undecoded response bytes"
6
+ )
7
+ p.set_defaults(run=run)
8
+
9
+ p = sub.add_parser("info", help="show serial number and firmware")
10
+ p.add_argument("address")
11
+ p.add_argument(
12
+ "--raw", action="store_true", help="also print the undecoded response bytes"
13
+ )
14
+ p.set_defaults(run=run)
15
+
16
+ p = sub.add_parser(
17
+ "device-info", help="show model, device ID and firmware versions"
18
+ )
19
+ p.add_argument("address")
20
+ p.add_argument(
21
+ "--raw", action="store_true", help="also print the undecoded response bytes"
22
+ )
23
+ p.set_defaults(run=run)
24
+
25
+
26
+ async def run(client, creds, args) -> list[str]:
27
+ method = {
28
+ "status": client.get_device_config,
29
+ "info": client.get_lock_sn,
30
+ "device-info": client.get_device_info,
31
+ }[args.command]
32
+ fields = await method()
33
+ lines = [
34
+ f"{key}: {value}" for key, value in fields.items() if key != "raw" or args.raw
35
+ ]
36
+ if args.command == "info":
37
+ lines.append(f"comm_version: {client.comm_version}")
38
+ return lines
@@ -0,0 +1,120 @@
1
+ from entr_ble import const
2
+
3
+ from ..shared import (
4
+ CONTROL_UNIT_PIN,
5
+ CONTROL_UNIT_ROLES,
6
+ ROLE_CHOICES,
7
+ CommandError,
8
+ add_password_argument,
9
+ generate_key_code,
10
+ require_admin,
11
+ user_role,
12
+ )
13
+
14
+
15
+ def register(sub):
16
+ p = sub.add_parser("list-users", help="list users")
17
+ p.add_argument("address")
18
+ add_password_argument(p)
19
+ p.set_defaults(run=run)
20
+
21
+ p = sub.add_parser(
22
+ "create-user", help="create a user and print its activation code"
23
+ )
24
+ p.add_argument("address")
25
+ add_password_argument(p)
26
+ p.add_argument("name", help="user name (up to 16 characters)")
27
+ p.add_argument(
28
+ "--role",
29
+ choices=ROLE_CHOICES,
30
+ default="user",
31
+ help="role for the new key",
32
+ )
33
+ p.add_argument(
34
+ "--expiration",
35
+ type=int,
36
+ default=3,
37
+ choices=const.EXPIRATION_HOURS,
38
+ help="hours the key code stays redeemable (default: 3)",
39
+ )
40
+ p.add_argument("--code", help="use this key code instead of generating one")
41
+ p.set_defaults(run=run)
42
+
43
+ p = sub.add_parser(
44
+ "set-admin-code",
45
+ help="set this admin key's password",
46
+ epilog="Choose six characters including a lowercase letter, an uppercase letter, and a digit from 1 to 9.",
47
+ )
48
+ p.add_argument("address")
49
+ add_password_argument(p, "New admin password: ")
50
+ p.set_defaults(run=run)
51
+
52
+ p = sub.add_parser("disable-user", help="suspend a user without revoking it")
53
+ p.add_argument("address")
54
+ add_password_argument(p)
55
+ p.add_argument("name")
56
+ p.set_defaults(run=run)
57
+
58
+ p = sub.add_parser("enable-user", help="re-enable a suspended user")
59
+ p.add_argument("address")
60
+ add_password_argument(p)
61
+ p.add_argument("name")
62
+ p.set_defaults(run=run)
63
+
64
+ p = sub.add_parser("delete-user", help="revoke a user permanently")
65
+ p.add_argument("address")
66
+ add_password_argument(p)
67
+ p.add_argument("name")
68
+ p.set_defaults(run=run)
69
+
70
+
71
+ async def run(client, creds, args) -> list[str]:
72
+ app_id = bytes.fromhex(creds.app_id)
73
+ if args.command == "list-users":
74
+ users = await client.list_users(args.admin_code, app_id)
75
+ return [
76
+ f"{u['name']} role={const.ROLE_NAMES.get(u['role'], u['role'])} state={const.STATE_NAMES.get(u['state'], u['state'])}"
77
+ for u in users
78
+ ]
79
+ if args.command == "create-user":
80
+ role = ROLE_CHOICES[args.role]
81
+ if role in CONTROL_UNIT_ROLES:
82
+ key_code, expiration = CONTROL_UNIT_PIN, 0
83
+ else:
84
+ key_code = args.code or generate_key_code()
85
+ expiration = args.expiration
86
+ await client.create_user(
87
+ args.admin_code,
88
+ app_id,
89
+ args.name,
90
+ key_code,
91
+ role=role,
92
+ expiration_hours=expiration,
93
+ )
94
+ lines = [f"created {args.name} as {const.ROLE_NAMES[role]}"]
95
+ if role in CONTROL_UNIT_ROLES:
96
+ return lines + [
97
+ "pair the accessory now (radio pairing happens on the hardware side)"
98
+ ]
99
+ return lines + [
100
+ f"key code: {key_code}",
101
+ f"redeem it within {expiration}h with: entr-ble-cli activate {creds.address} {key_code}",
102
+ ]
103
+ if args.command == "set-admin-code":
104
+ if creds.role != const.ROLE_ADMIN:
105
+ raise CommandError("only an admin can set an admin code")
106
+ await client.set_admin_code(
107
+ bytes.fromhex(creds.user_id), app_id, args.admin_code
108
+ )
109
+ return [
110
+ "admin code set, it is now needed for list-users, create-user and delete-user"
111
+ ]
112
+ if args.command in {"delete-user", "disable-user", "enable-user"}:
113
+ require_admin(creds)
114
+ role = await user_role(client, args.admin_code, app_id, args.name)
115
+ await getattr(client, args.command.replace("-user", "_user"))(
116
+ args.admin_code, app_id, args.name, role
117
+ )
118
+ verb = args.command.split("-")[0]
119
+ return [f"{verb}d {args.name} ({const.ROLE_NAMES.get(role, role)})"]
120
+ raise ValueError(args.command)
entr_ble_cli/shared.py ADDED
@@ -0,0 +1,76 @@
1
+ import secrets
2
+
3
+ from entr_ble import const
4
+ from entr_ble.client import EntrProtocolError
5
+
6
+ from .store import LockCredentials
7
+
8
+ ROLE_CHOICES = {
9
+ "user": const.ROLE_USER,
10
+ "admin": const.ROLE_ADMIN,
11
+ "remote-control": const.ROLE_REMOTE_CONTROL,
12
+ "wall-reader": const.ROLE_WALL_READER,
13
+ "integration-unit": const.ROLE_INTEGRATION_UNIT,
14
+ }
15
+ VOLUME_CHOICES = {
16
+ "high": const.VOLUME_HIGH,
17
+ "medium": const.VOLUME_MEDIUM,
18
+ "low": const.VOLUME_LOW,
19
+ "muted": const.VOLUME_MUTED,
20
+ }
21
+ CONTROL_UNIT_ROLES = (
22
+ const.ROLE_REMOTE_CONTROL,
23
+ const.ROLE_WALL_READER,
24
+ const.ROLE_INTEGRATION_UNIT,
25
+ )
26
+ CONTROL_UNIT_PIN = "p7G513"
27
+ _KEY_CODE_UPPER = "ABCDEFGHJKLMNPQRSTUVWXYZ"
28
+ _KEY_CODE_LOWER = "abcdefghijkmnopqrstuvwxyz"
29
+ _KEY_CODE_DIGITS = "123456789"
30
+ _KEY_CODE_ALPHABET = _KEY_CODE_UPPER + _KEY_CODE_LOWER + _KEY_CODE_DIGITS
31
+
32
+
33
+ class CommandError(Exception):
34
+ pass
35
+
36
+
37
+ def add_password_argument(parser, prompt="Admin password: "):
38
+ parser.add_argument(
39
+ "-p",
40
+ "--password",
41
+ dest="admin_code",
42
+ metavar="PASSWORD",
43
+ help=f"{prompt.removesuffix(': ')} (6 characters; asked securely if omitted)",
44
+ )
45
+ parser.set_defaults(password_prompt=prompt)
46
+
47
+
48
+ def generate_key_code() -> str:
49
+ while True:
50
+ code = "".join(
51
+ secrets.choice(_KEY_CODE_ALPHABET) for _ in range(const.KEY_CODE_LENGTH)
52
+ )
53
+ if (
54
+ any(c in _KEY_CODE_UPPER for c in code)
55
+ and any(c in _KEY_CODE_LOWER for c in code)
56
+ and any(c in _KEY_CODE_DIGITS for c in code)
57
+ ):
58
+ return code
59
+
60
+
61
+ def require_admin(creds: LockCredentials) -> None:
62
+ if creds.role not in (const.ROLE_ADMIN, const.ROLE_OWNER):
63
+ raise CommandError(
64
+ f"this needs an admin or owner key, this one is a {const.ROLE_NAMES.get(creds.role, creds.role)}"
65
+ )
66
+
67
+
68
+ async def user_role(client, admin_code: str, app_id: bytes, name: str) -> int:
69
+ users = await client.list_users(admin_code, app_id)
70
+ for user in users:
71
+ if user["name"] == name:
72
+ return user["role"]
73
+ known = ", ".join(u["name"] for u in users) or "none"
74
+ raise EntrProtocolError(
75
+ f"no user named {name!r} on this lock (known users: {known})"
76
+ )
entr_ble_cli/store.py ADDED
@@ -0,0 +1,96 @@
1
+ import json
2
+ import os
3
+ import tempfile
4
+ from dataclasses import asdict, dataclass
5
+ from pathlib import Path
6
+
7
+ DEFAULT_STORE_PATH = Path(
8
+ os.environ.get(
9
+ "ENTR_BLE_STORE", Path.home() / ".config" / "entr-ble" / "credentials.json"
10
+ )
11
+ )
12
+
13
+
14
+ @dataclass
15
+ class LockCredentials:
16
+ address: str
17
+ app_id: str # hex
18
+ user_id: str # hex
19
+ ble_ekey: str # hex
20
+ kdf_id: int
21
+ aes_key: str # hex
22
+ comm_version: str
23
+ role: int = 2 # const.ROLE_OWNER; the KDF handshake replays it every connection
24
+ lock_name: str | None = None # needed again by OP_DEVICE_CONFIG frames
25
+
26
+
27
+ def get(address: str, path: Path = DEFAULT_STORE_PATH) -> LockCredentials | None:
28
+ return _load(path).get(address.upper())
29
+
30
+
31
+ def put(creds: LockCredentials, path: Path = DEFAULT_STORE_PATH) -> None:
32
+ store = _load(path)
33
+ store[creds.address.upper()] = creds
34
+ _save(store, path)
35
+
36
+
37
+ def remove(address: str, path: Path = DEFAULT_STORE_PATH) -> None:
38
+ store = _load(path)
39
+ if store.pop(address.upper(), None) is not None:
40
+ _save(store, path)
41
+
42
+
43
+ def _load(path: Path) -> dict[str, LockCredentials]:
44
+ if not path.exists():
45
+ return {}
46
+ try:
47
+ data = json.loads(path.read_text(encoding="utf-8"))
48
+ if not isinstance(data, dict):
49
+ raise TypeError("expected an object indexed by Bluetooth address")
50
+ return {
51
+ addr.upper(): _credentials(addr, fields) for addr, fields in data.items()
52
+ }
53
+ except (ValueError, TypeError) as exc:
54
+ raise ValueError(f"Invalid credentials file: {path}") from exc
55
+
56
+
57
+ def _credentials(address, fields):
58
+ creds = LockCredentials(**fields)
59
+ if not isinstance(creds.address, str) or creds.address.upper() != address.upper():
60
+ raise ValueError("address does not match credential entry")
61
+ for name, size in (
62
+ ("app_id", 16),
63
+ ("user_id", 16),
64
+ ("ble_ekey", 32),
65
+ ("aes_key", 16),
66
+ ):
67
+ value = getattr(creds, name)
68
+ if not isinstance(value, str) or len(bytes.fromhex(value)) != size:
69
+ raise ValueError(f"invalid {name}")
70
+ for value in (creds.kdf_id, creds.role):
71
+ if type(value) is not int or not 0 <= value <= 255:
72
+ raise ValueError("invalid credential identifier")
73
+ if not isinstance(creds.comm_version, str) or (
74
+ creds.lock_name is not None and not isinstance(creds.lock_name, str)
75
+ ):
76
+ raise ValueError("invalid lock metadata")
77
+ return creds
78
+
79
+
80
+ def _save(store: dict[str, LockCredentials], path: Path) -> None:
81
+ path.parent.mkdir(parents=True, exist_ok=True)
82
+ data = {addr: asdict(creds) for addr, creds in store.items()}
83
+ temporary = None
84
+ try:
85
+ with tempfile.NamedTemporaryFile(
86
+ mode="w", encoding="utf-8", dir=path.parent, delete=False
87
+ ) as stream:
88
+ temporary = Path(stream.name)
89
+ json.dump(data, stream, indent=2)
90
+ stream.write("\n")
91
+ stream.flush()
92
+ os.fsync(stream.fileno())
93
+ temporary.replace(path)
94
+ finally:
95
+ if temporary is not None:
96
+ temporary.unlink(missing_ok=True)
@@ -0,0 +1,203 @@
1
+ Metadata-Version: 2.4
2
+ Name: entr-ble-cli
3
+ Version: 1.0.0
4
+ Summary: Command-line interface for ENTR Bluetooth locks
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Dist: entr-ble==1.0.0
8
+ Requires-Python: >=3.14
9
+ Project-URL: Repository, https://github.com/johannlejeune/entr-ble
10
+ Project-URL: Issues, https://github.com/johannlejeune/entr-ble/issues
11
+ Description-Content-Type: text/markdown
12
+
13
+ # ENTR BLE · CLI
14
+
15
+ **Set up and control your ENTR lock from the terminal.** Discover locks, manage user keys, change settings, and export credentials to Home Assistant.
16
+
17
+ [Project overview](../../README.md) · [Python library](../../src/entr_ble/README.md) · [Home Assistant](../../custom_components/entr_ble/README.md) · [Feature coverage](../../docs/FEATURES.md)
18
+
19
+ ## Install
20
+
21
+ Requires **Python 3.14+** and a working Bluetooth adapter. On Linux, Bluetooth access requires BlueZ and access to the system D-Bus.
22
+
23
+ Run the CLI without a checkout:
24
+
25
+ ```sh
26
+ uvx entr-ble-cli --help
27
+ uvx entr-ble-cli scan
28
+ ```
29
+
30
+ For a persistent command, use `uv tool install entr-ble-cli`, then run `entr-ble-cli` directly. With `uvx`, prefix the commands below with `uvx`.
31
+
32
+ Alternatively, from a checkout, run:
33
+
34
+ ```sh
35
+ uv sync --locked --package entr-ble-cli
36
+ uv run --package entr-ble-cli entr-ble-cli --help
37
+ ```
38
+
39
+ For the checkout workflow, prefix the commands below with `uv run --package entr-ble-cli`. The `entr-ble-cli` package supplies the `entr-ble-cli` command; installing the `entr-ble` library alone does not.
40
+
41
+ ## Quick start
42
+
43
+ ### 1. Find your lock
44
+
45
+ ```sh
46
+ entr-ble-cli scan
47
+ ```
48
+
49
+ The scan lists each lock's address, advertised name, initialization state, and signal strength. Replace `ADDRESS` in the examples below with the exact address shown by your scan.
50
+
51
+ For a longer scan, or to include other BLE devices:
52
+
53
+ ```sh
54
+ entr-ble-cli scan --timeout 10
55
+ entr-ble-cli scan --all
56
+ ```
57
+
58
+ ### 2. Choose how to access the lock
59
+
60
+ Run **one** of the following setup commands. Each saves the resulting credential locally for subsequent commands.
61
+
62
+ | Your situation | Command | Effect |
63
+ | --- | --- | --- |
64
+ | New, uninitialized lock | `entr-ble-cli set-owner ADDRESS --name "Front door"` | Creates the first owner and asks for a new password |
65
+ | Initialized lock; you want owner access | `entr-ble-cli enroll ADDRESS --name "Front door"` | Asks for the current owner password and replaces the owner credential |
66
+ | You have an activation code from the owner | `entr-ble-cli activate ADDRESS KEY_CODE` | Redeems an additional key while preserving the owner |
67
+
68
+ > [!WARNING]
69
+ > `enroll` replaces the lock's single owner slot. The previous owner, including the mobile app if it holds that slot, loses access. Use `activate` to keep the existing owner.
70
+
71
+ `KEY_CODE` is the six-character code supplied by the owner. A setup command refuses to overwrite credentials already saved for that address.
72
+
73
+ For a new lock, `--name` accepts up to 12 bytes. `--user` sets the owner name, and `--provider` selects the brand id; see `entr-ble-cli set-owner --help`. After initial setup, an uncalibrated lock needs `calibrate` followed by `magnet-calibrate` with the door magnet in place.
74
+
75
+ ### 3. Read status and control the lock
76
+
77
+ ```sh
78
+ entr-ble-cli status ADDRESS
79
+ entr-ble-cli unlock ADDRESS
80
+ entr-ble-cli lock ADDRESS
81
+ ```
82
+
83
+ Commands restore your saved session and close the Bluetooth connection when finished. Unlock behavior follows the lock's auto-lock setting. Reported state can be stale after manual operation; see [protocol limitations](../../docs/PROTOCOL.md).
84
+
85
+ ## Everyday tasks
86
+
87
+ ### Share access
88
+
89
+ With an owner or admin key, create a user:
90
+
91
+ ```sh
92
+ entr-ble-cli create-user ADDRESS Guest
93
+ ```
94
+
95
+ The command asks for the admin password and prints an activation code and the command to redeem it. Run that activation command on the recipient's machine with its own credential store.
96
+
97
+ The code stays redeemable for **three hours by default**. `--expiration` changes that window; the activated key does not expire when the code's redemption window ends.
98
+
99
+ ```sh
100
+ entr-ble-cli list-users ADDRESS
101
+ entr-ble-cli disable-user ADDRESS Guest
102
+ entr-ble-cli enable-user ADDRESS Guest
103
+ entr-ble-cli delete-user ADDRESS Guest
104
+ ```
105
+
106
+ Disable suspends a key; enable restores it; delete permanently revokes it. `create-user --role admin` creates an admin key. After activation, that admin uses `set-admin-code` to set its personal password. Accessory roles are also available; radio pairing happens on the hardware, as described in [feature coverage](../../docs/FEATURES.md#users-and-accessories).
107
+
108
+ ### Change settings
109
+
110
+ ```sh
111
+ entr-ble-cli settings ADDRESS --volume medium --auto-lock on
112
+ entr-ble-cli change-admin-code ADDRESS
113
+ ```
114
+
115
+ Volume choices are `high`, `medium`, `low`, and `muted`. These commands require an owner or admin key. The CLI reads the current configuration before applying changes. If the lock name was not saved during enrollment, pass `--name "Front door"` once; the CLI saves it for later settings commands.
116
+
117
+ ### Bring an existing key into Home Assistant
118
+
119
+ ```sh
120
+ entr-ble-cli export-homeassistant ADDRESS
121
+ ```
122
+
123
+ In the [integration setup](../../custom_components/entr_ble/README.md#use-credentials-from-the-cli), choose **Import existing credentials**, enter the same address, and paste the output into **Credentials JSON**.
124
+
125
+ Export reads the local store without connecting to the lock or changing credentials. Home Assistant stores its own copy. The JSON contains access keys: keep it private.
126
+
127
+ ## Command reference
128
+
129
+ Run `entr-ble-cli` to see grouped help, or `entr-ble-cli COMMAND --help` for arguments and options. All lock commands below take `ADDRESS`; `scan` does not.
130
+
131
+ | Group | Commands | Purpose |
132
+ | --- | --- | --- |
133
+ | Discovery and setup | `scan`, `set-owner`, `enroll`, `activate` | Find locks and save an access credential |
134
+ | Lock control | `lock`, `unlock` | Operate the lock |
135
+ | Status and information | `status`, `info`, `device-info` | Read status, serial number, model, and firmware |
136
+ | Users and keys | `list-users`, `create-user`, `set-admin-code`, `disable-user`, `enable-user`, `delete-user` | Manage access |
137
+ | Settings | `settings`, `change-admin-code` | Change volume, auto-lock, name, or admin password |
138
+ | Maintenance | `calibrate`, `magnet-calibrate`, `factory-reset` | Calibrate hardware or restore factory settings |
139
+ | Clock and logs | `set-time`, `audit-trail`, `get-errors` | Set UTC time and read diagnostics |
140
+ | Home Assistant | `export-homeassistant` | Export one saved credential as JSON |
141
+
142
+ `set-time` and `audit-trail` require NIZ firmware. `device-info` requires the FOTA GATT service and communication version 1.29r3+; `get-errors` requires the FOTA service. See [feature coverage](../../docs/FEATURES.md) for the full compatibility matrix.
143
+
144
+ > [!WARNING]
145
+ > `factory-reset` erases lock users and settings, then removes the local credential after success. It asks you to type `yes`; `--yes` skips that confirmation.
146
+
147
+ ## Passwords and scripting
148
+
149
+ Commands needing a password ask for it **without echo**, before connecting. `set-owner` and `set-admin-code` ask for the password to set; `change-admin-code` asks for both current and new passwords. At the `audit-trail` prompt, press Enter to use the factory audit password.
150
+
151
+ For scripts without a terminal, use `-p PASSWORD` or `--password PASSWORD`. For password changes, also supply `--new-password NEW`. Command-line passwords may appear in shell history and process arguments; prefer the hidden prompt when working interactively.
152
+
153
+ Results go to **stdout**, progress to **stderr**. Add `-q` or `--quiet` before or after a command to suppress progress while keeping warnings and errors:
154
+
155
+ ```sh
156
+ entr-ble-cli --quiet status ADDRESS > status.txt
157
+ entr-ble-cli status ADDRESS --quiet
158
+ ```
159
+
160
+ Expected Bluetooth and lock failures produce a short error and exit with code `1`; interrupted input exits with `130`. Unexpected internal errors retain their traceback. Ordinary command output is human-readable; `export-homeassistant` produces JSON.
161
+
162
+ ## Credential storage
163
+
164
+ | Setting | Value |
165
+ | --- | --- |
166
+ | Default store | `~/.config/entr-ble/credentials.json` |
167
+ | Override | `ENTR_BLE_STORE` environment variable |
168
+ | Protection | Access restricted to the current user; unencrypted at rest |
169
+ | Writes | Atomic file replacement; no coordination between concurrent writers |
170
+
171
+ Keep the store private, backed up securely, and out of version control. Use one process at a time when modifying a shared store. An alternate store can keep credentials for a separate client:
172
+
173
+ ```sh
174
+ export ENTR_BLE_STORE="$HOME/.config/entr-ble/guest.json"
175
+ entr-ble-cli activate ADDRESS KEY_CODE
176
+ ```
177
+
178
+ Subsequent commands in that shell use the alternate store.
179
+
180
+ ## Troubleshooting
181
+
182
+ | Symptom | Next step |
183
+ | --- | --- |
184
+ | No lock found or communication times out | Move closer, check Bluetooth is enabled, and try `scan --timeout 10 --all` |
185
+ | Bluetooth access denied | Check application Bluetooth permissions; on Linux, check BlueZ and system D-Bus access |
186
+ | No saved key | Check the address and `ENTR_BLE_STORE`; choose the appropriate setup method above |
187
+ | Lock refuses a command | Check the password, key permissions, and firmware support |
188
+ | Settings command needs a name | Pass `--name` once to save the lock name locally |
189
+ | Status disagrees with manual operation | Read the [firmware state limitations](../../docs/PROTOCOL.md); a reported value can be stale |
190
+
191
+ For a bug report, include the command, error, software version, and lock model/firmware. Remove passwords and credential material before sharing output.
192
+
193
+ ## Development
194
+
195
+ From the repository root, after the checkout installation above:
196
+
197
+ ```sh
198
+ uv run --package entr-ble-cli python -m unittest discover -s packages/entr-ble-cli/tests -v
199
+ ```
200
+
201
+ Tests use simulated BLE responses and do not operate a lock. See the [workspace development guide](../../README.md#development) for all checks.
202
+
203
+ Licensed under the [MIT License](LICENSE).
@@ -0,0 +1,19 @@
1
+ entr_ble_cli/__init__.py,sha256=iyDYjHaLo7laVwWM-l3lE_9MF0EyefXhqiHNV2hnZTo,95
2
+ entr_ble_cli/cli.py,sha256=I8-f4W8Df2hWYGkb5lVi6tUGrDdDrlpPETd2oOfO9mI,5277
3
+ entr_ble_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ entr_ble_cli/commands/access.py,sha256=vG2NIEVziAITH_cnzqeXYOAVtJi4m16sbCI-gXkHC94,497
5
+ entr_ble_cli/commands/common.py,sha256=v-mCNGonKRPHS0QQcL-e2C9ZmzMoaySdtdFH-kc8omU,3094
6
+ entr_ble_cli/commands/config.py,sha256=8tzP17d-9TNuWIsz9pbkoyTvx-9Y8scsBEFyG8TJ9BM,1093
7
+ entr_ble_cli/commands/discovery.py,sha256=GYXg8q2yIBq41dsX9gFDxKMGCFMHfWPaZRJZStm2BX8,1433
8
+ entr_ble_cli/commands/maintenance.py,sha256=ljkZAYYCACLtiKoJm4FjgkyTeEBZAasS-7nlVP7J8II,4000
9
+ entr_ble_cli/commands/settings.py,sha256=pqNz7Y7-3GkWBgxE0vUyRWUEEAnDAZcFBQS0bYs3Lvo,3151
10
+ entr_ble_cli/commands/setup.py,sha256=2kO10mHLL8ojVYfwZ4lbPGllPn4eCqZZsiYsxPehQfU,3751
11
+ entr_ble_cli/commands/status.py,sha256=UBQp4auCpuE7MYq32a2K_Hwt9u2d4epo1NBqeonLRAg,1236
12
+ entr_ble_cli/commands/users.py,sha256=-NJvuBbUVlyiqLQPFbZ40FbzdMnVpbkT1ZfEenX93o8,4076
13
+ entr_ble_cli/shared.py,sha256=OTAeU1P_8-BYDlQSwaoOj2qYF1kH3aEhBTcifffvr48,2237
14
+ entr_ble_cli/store.py,sha256=b0F8OajUWFvuFGcgAVZEfUOeCMXaZ0ojeGw8KW-Y2gs,3120
15
+ entr_ble_cli-1.0.0.dist-info/licenses/LICENSE,sha256=kW6bEOhiZYBVbMnSrzF4DA5wCF32ZcngmKzrNawq6Oc,1071
16
+ entr_ble_cli-1.0.0.dist-info/WHEEL,sha256=VOHKyukui3UMeyUkkBf5F7N5xN_6X6W7zHVvalNVVvI,81
17
+ entr_ble_cli-1.0.0.dist-info/entry_points.txt,sha256=dWjKA9Vj259tSo7h5F159GDGJKK5x89YNVviFhu-YCo,52
18
+ entr_ble_cli-1.0.0.dist-info/METADATA,sha256=tlHop8BbP9TRlmTNaQ1946RAkCw2j530yHH-YmX9Kqs,9878
19
+ entr_ble_cli-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.21
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ entr-ble-cli = entr_ble_cli:main
3
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Johann Lejeune
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.