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.
- entr_ble_cli/__init__.py +7 -0
- entr_ble_cli/cli.py +132 -0
- entr_ble_cli/commands/__init__.py +0 -0
- entr_ble_cli/commands/access.py +16 -0
- entr_ble_cli/commands/common.py +78 -0
- entr_ble_cli/commands/config.py +28 -0
- entr_ble_cli/commands/discovery.py +43 -0
- entr_ble_cli/commands/maintenance.py +112 -0
- entr_ble_cli/commands/settings.py +103 -0
- entr_ble_cli/commands/setup.py +110 -0
- entr_ble_cli/commands/status.py +38 -0
- entr_ble_cli/commands/users.py +120 -0
- entr_ble_cli/shared.py +76 -0
- entr_ble_cli/store.py +96 -0
- entr_ble_cli-1.0.0.dist-info/METADATA +203 -0
- entr_ble_cli-1.0.0.dist-info/RECORD +19 -0
- entr_ble_cli-1.0.0.dist-info/WHEEL +4 -0
- entr_ble_cli-1.0.0.dist-info/entry_points.txt +3 -0
- entr_ble_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
entr_ble_cli/__init__.py
ADDED
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,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.
|