hoermoles-ble-cli 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ # Reverse-engineering artifacts (decompiles, APKs, analysis notes) - not part of the product
2
+ /reveng/
3
+
4
+ # Python
5
+ __pycache__/
6
+ *.pyc
7
+ .venv/
8
+ *.egg-info/
9
+
10
+ # Local configuration (incl. HOERMOLES_CONF_DIR for development)
11
+ .env
12
+ .env.*
13
+ .hoermoles/
@@ -0,0 +1,35 @@
1
+ Metadata-Version: 2.4
2
+ Name: hoermoles-ble-cli
3
+ Version: 0.2.0
4
+ Summary: CLI test tool for hoermoles-ble (registration, channel control, scan)
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: hoermoles-ble[bleak]
7
+ Description-Content-Type: text/markdown
8
+
9
+ # hoermoles-ble-cli
10
+
11
+ Thin CLI wrapper around `hoermoles-ble`. Install standalone with
12
+ `uv tool install hoermoles-ble-cli` (gives you the `hoermoles-ble` command
13
+ directly), or, from a workspace checkout, `uv sync` in the workspace root and
14
+ prefix every command below with `uv run`:
15
+
16
+ ```
17
+ hoermoles-ble scan
18
+ hoermoles-ble list-devices # show known drives + product type
19
+ hoermoles-ble register --address <MAC> --qr-file <path-to-qr-code.txt>
20
+ hoermoles-ble exec --address <MAC> open
21
+ hoermoles-ble menu-get --address <MAC> 52 # read one operator menu
22
+ hoermoles-ble menu-set --address <MAC> 52=1 # write one operator menu
23
+ hoermoles-ble view-log --address <MAC> # audit log + diagnostics counters
24
+ ```
25
+
26
+ `scan` (and `register`, via the QR code) saves each drive's product type to a
27
+ device registry (`<config-dir>/devices.json` - see `hoermoles_ble.devices`);
28
+ `menu-get`/`menu-set` use that to pick the right menu table automatically
29
+ (see `hoermoles_ble.menu_settings` for the known products - run `scan` near
30
+ the drive first if `list-devices` doesn't show it yet). Without menu
31
+ numbers, `menu-get` reads the entire table.
32
+
33
+ `view-log` shows the drive's security/access audit log (who registered,
34
+ channel toggles, blocked login attempts, ...) and service/diagnostics
35
+ counters (operating hours, door cycles, ...) - see `hoermoles_ble.device_log`.
@@ -0,0 +1,27 @@
1
+ # hoermoles-ble-cli
2
+
3
+ Thin CLI wrapper around `hoermoles-ble`. Install standalone with
4
+ `uv tool install hoermoles-ble-cli` (gives you the `hoermoles-ble` command
5
+ directly), or, from a workspace checkout, `uv sync` in the workspace root and
6
+ prefix every command below with `uv run`:
7
+
8
+ ```
9
+ hoermoles-ble scan
10
+ hoermoles-ble list-devices # show known drives + product type
11
+ hoermoles-ble register --address <MAC> --qr-file <path-to-qr-code.txt>
12
+ hoermoles-ble exec --address <MAC> open
13
+ hoermoles-ble menu-get --address <MAC> 52 # read one operator menu
14
+ hoermoles-ble menu-set --address <MAC> 52=1 # write one operator menu
15
+ hoermoles-ble view-log --address <MAC> # audit log + diagnostics counters
16
+ ```
17
+
18
+ `scan` (and `register`, via the QR code) saves each drive's product type to a
19
+ device registry (`<config-dir>/devices.json` - see `hoermoles_ble.devices`);
20
+ `menu-get`/`menu-set` use that to pick the right menu table automatically
21
+ (see `hoermoles_ble.menu_settings` for the known products - run `scan` near
22
+ the drive first if `list-devices` doesn't show it yet). Without menu
23
+ numbers, `menu-get` reads the entire table.
24
+
25
+ `view-log` shows the drive's security/access audit log (who registered,
26
+ channel toggles, blocked login attempts, ...) and service/diagnostics
27
+ counters (operating hours, door cycles, ...) - see `hoermoles_ble.device_log`.
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "hoermoles-ble-cli"
3
+ version = "0.2.0"
4
+ description = "CLI test tool for hoermoles-ble (registration, channel control, scan)"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "hoermoles-ble[bleak]",
9
+ ]
10
+
11
+ [project.scripts]
12
+ hoermoles-ble = "hoermoles_ble_cli.cli:main"
13
+
14
+ [build-system]
15
+ requires = ["hatchling"]
16
+ build-backend = "hatchling.build"
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["src/hoermoles_ble_cli"]
20
+
21
+ [tool.uv.sources]
22
+ hoermoles-ble = { workspace = true }
@@ -0,0 +1,459 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Thin CLI wrapper around the hoermoles-ble library (package hoermoles_ble).
4
+ All protocol/crypto logic lives entirely in the library - this script only
5
+ handles argument parsing and I/O.
6
+
7
+ Without --key-file, credentials are stored under <config-dir>/credentials/<address>.json
8
+ and loaded from there. config-dir is resolved in this order:
9
+ 1. --config-dir 2. environment variable HOERMOLES_CONF_DIR 3. HOERMOLES_CONF_DIR
10
+ from a .env file 4. default ~/.hoermoles (see hoermoles_ble/config.py).
11
+
12
+ Until there is an app with camera QR scanning, QR code contents can be
13
+ collected manually (e.g. photographed/typed off, one QR code per save-qr call).
14
+ 'register' and 'scan' match them to the right device automatically via the
15
+ serial number shared between the QR code and the BLE advertisement:
16
+ uv run hoermoles-ble save-qr "<QR code content>"
17
+
18
+ Examples (after `uv sync` in the workspace root python/):
19
+ uv run hoermoles-ble scan
20
+
21
+ uv run hoermoles-ble register --address F1:26:AF:CC:41:86
22
+
23
+ uv run hoermoles-ble exec --address F1:26:AF:CC:41:86 open
24
+
25
+ uv run hoermoles-ble menu-get --address F1:26:AF:CC:41:86 52
26
+ uv run hoermoles-ble menu-set --address F1:26:AF:CC:41:86 52=1
27
+
28
+ uv run hoermoles-ble view-log --address F1:26:AF:CC:41:86
29
+ """
30
+ import argparse
31
+ import asyncio
32
+ import time
33
+
34
+ from hoermoles_ble import (
35
+ Credentials,
36
+ DeviceInfo,
37
+ DriveMenuTable,
38
+ HoermannClient,
39
+ LOG_TAG_NAMES,
40
+ PropertiesRejected,
41
+ RegistrationTimeout,
42
+ GATE_ACTIONS,
43
+ SERVICE_TYPE_IS_TIMESTAMP,
44
+ SERVICE_TYPE_NAMES,
45
+ default_credentials_path,
46
+ get_device_info,
47
+ list_device_infos,
48
+ log_timestamp_to_datetime,
49
+ menu_setting_for_wire_group,
50
+ menu_table_for_product,
51
+ parse_log_fields,
52
+ product_class_and_id_from_qr_prefix,
53
+ save_device_info,
54
+ scan_devices,
55
+ find_qr_for_address,
56
+ save_qr,
57
+ known_qr_serial_map,
58
+ serial_no_from_qr_prefix,
59
+ wire_group_for_menu_number,
60
+ )
61
+ from hoermoles_ble.advertisement import PRODUCT_TYPE_NAMES
62
+ from hoermoles_ble.ble_transport import BleakTransport
63
+ from hoermoles_ble.protocol import parse_qr_code
64
+
65
+
66
+ def log(msg: str) -> None:
67
+ print(f"[{time.strftime('%H:%M:%S')}] {msg}")
68
+
69
+
70
+ async def cmd_scan(args) -> None:
71
+ known = known_qr_serial_map(config_dir=args.config_dir)
72
+
73
+ log(f"Scanning for {args.timeout:.0f}s for Hoermann BlueSecur drives...")
74
+ devices = await scan_devices(timeout=args.timeout, adapter=args.adapter)
75
+ if not devices:
76
+ print("No devices found.")
77
+ return
78
+ for info in devices:
79
+ print(f"\n{info.address} (RSSI {info.rssi} dBm)")
80
+ if info.product_class is not None:
81
+ device_info = DeviceInfo(info.address, info.product_class, info.product_id,
82
+ info.product_name, info.serial_no)
83
+ save_device_info(device_info, config_dir=args.config_dir)
84
+ if info.product_name:
85
+ print(f" Product: {info.product_name} "
86
+ f"(class={info.product_class}, id={info.product_id})")
87
+ if info.serial_no is not None:
88
+ qr_status = "yes" if info.serial_no in known else f"no (0 of {len(known)} known QR codes match)"
89
+ print(f" QR code known: {qr_status}")
90
+ if info.admin_teached is not None:
91
+ print(f" Admin taught: {info.admin_teached}")
92
+ print(f" New registration ok: {info.admins_can_be_teached}")
93
+ print(f" Protection active: {info.protection_active}")
94
+ print(f" Clock set: {info.clock_time_set}")
95
+ print(f" In motion: {info.in_action}")
96
+ print(f" Warning time running: {info.warning_time}")
97
+ print(f" Emergency mode (batt.): {info.emergency_mode}")
98
+ print(f" Battery low: {info.low_battery}")
99
+ print(f" Vacation mode: {info.vacation_mode}")
100
+ if info.relais1_open is not None:
101
+ print(f" Relay 1 open: {info.relais1_open}")
102
+ print(f" Relay 2 open: {info.relais2_open}")
103
+ if info.opening_progress_percent is not None:
104
+ print(f" Opening progress: {info.opening_progress_percent:.0f}%")
105
+ print(f" Maintenance due: {info.maintenance_required}")
106
+ if info.parse_error:
107
+ print(f" (incompletely parsed: {info.parse_error})")
108
+ print(f" Raw data (Manufacturer Data 1972): {info.raw_manufacturer_data}")
109
+
110
+
111
+ async def cmd_save_qr(args) -> None:
112
+ saved_path = save_qr(args.content, config_dir=args.config_dir)
113
+ log(f"QR code saved to {saved_path}")
114
+
115
+
116
+ async def cmd_register(args) -> None:
117
+ if args.qr_file:
118
+ with open(args.qr_file, "r") as f:
119
+ qr_text = f.read()
120
+ else:
121
+ log(f"No --qr-file given, looking for a matching saved QR code for {args.address} "
122
+ f"(scanning for up to {args.timeout:.0f}s)...")
123
+ qr_text = await find_qr_for_address(args.address, timeout=args.timeout, adapter=args.adapter,
124
+ config_dir=args.config_dir)
125
+ if qr_text is None:
126
+ raise SystemExit(
127
+ f"No saved QR code matches {args.address} (or the device wasn't found) - "
128
+ "run 'save-qr' first or pass --qr-file."
129
+ )
130
+
131
+ async with HoermannClient(BleakTransport(args.address, adapter=args.adapter, on_log=log), on_log=log) as client:
132
+ log(f"Connected to {args.address}, waiting for the first notification...")
133
+ try:
134
+ await client.wait_for_any_notification(timeout=10.0)
135
+ except asyncio.TimeoutError:
136
+ log("WARNING: no initial notification received, continuing anyway")
137
+
138
+ log("Sending registration (SET_REGISTER_KEY)...")
139
+ try:
140
+ credentials = await client.register(qr_text, args.address)
141
+ except RegistrationTimeout as exc:
142
+ log(f"ERROR: {exc}")
143
+ raise SystemExit(1)
144
+
145
+ log(f"RootID = {credentials.root_id}")
146
+ log(f"Root key = {credentials.root_key.hex()}")
147
+ saved_path = credentials.save(args.key_file, config_dir=args.config_dir)
148
+ log(f"Saved to {saved_path}")
149
+
150
+ prefix, _ = parse_qr_code(qr_text)
151
+ product_info = product_class_and_id_from_qr_prefix(prefix)
152
+ if product_info is not None:
153
+ product_class, product_id = product_info
154
+ product_name = (PRODUCT_TYPE_NAMES.get((product_class, product_id))
155
+ or PRODUCT_TYPE_NAMES.get((product_class, None)))
156
+ device_info = DeviceInfo(args.address, product_class, product_id,
157
+ product_name, serial_no_from_qr_prefix(prefix))
158
+ devices_path = save_device_info(device_info, config_dir=args.config_dir)
159
+ log(f"Product type: {product_name or 'unknown'} (class={product_class}, id={product_id}), "
160
+ f"saved to {devices_path}")
161
+ else:
162
+ log("Could not determine the product type from the QR code prefix (non-version-3 layout?) - "
163
+ "run 'scan' near the drive to detect it instead.")
164
+
165
+
166
+ def _load_credentials(args) -> Credentials:
167
+ if args.key_file:
168
+ return Credentials.load(args.key_file)
169
+ if args.address:
170
+ return Credentials.load_for_device(args.address, config_dir=args.config_dir)
171
+ try:
172
+ credentials = Credentials.load_first(config_dir=args.config_dir)
173
+ except FileNotFoundError as exc:
174
+ raise SystemExit(str(exc)) from exc
175
+ log(f"No --address given, using the only/first saved credentials: {credentials.device_address}")
176
+ return credentials
177
+
178
+
179
+ async def cmd_exec(args) -> None:
180
+ credentials = _load_credentials(args)
181
+ address = args.address or credentials.device_address
182
+ channel = GATE_ACTIONS[args.action]
183
+
184
+ async with HoermannClient(BleakTransport(address, adapter=args.adapter, on_log=log), on_log=log) as client:
185
+ log(f"Connected to {address}, waiting for the initial notification (challenge)...")
186
+ try:
187
+ await client.wait_for_any_notification(timeout=10.0)
188
+ except asyncio.TimeoutError:
189
+ log("WARNING: no initial notification, challenge may be stale")
190
+
191
+ log(f"Sending '{args.action}' (CHANNEL_{channel}, RootID={credentials.root_id})...")
192
+ await client.open_channel(credentials, channel=channel)
193
+
194
+ log("Waiting for a response/status notification...")
195
+ try:
196
+ await client.wait_for_any_notification(timeout=5.0)
197
+ except asyncio.TimeoutError:
198
+ pass
199
+
200
+ log("Done.")
201
+
202
+
203
+ def _menu_group_or_exit(table: DriveMenuTable, menu_number: int) -> int:
204
+ group = wire_group_for_menu_number(table.settings, menu_number)
205
+ if group is None:
206
+ raise SystemExit(
207
+ f"Menu {menu_number} isn't in the {table.product_name} menu table (hoermoles_ble.menu_settings)."
208
+ )
209
+ return group
210
+
211
+
212
+ def _resolve_menu_table(args, credentials: Credentials) -> DriveMenuTable:
213
+ """Figures out which hoermoles_ble.menu_settings.DriveMenuTable applies to this drive
214
+ from the device registry (see 'scan'/'register'/list-devices - a plain 'scan' near the
215
+ drive is enough, no credentials/pairing needed for that part)."""
216
+ device_info = get_device_info(credentials.device_address, config_dir=args.config_dir)
217
+ if device_info is None:
218
+ raise SystemExit(
219
+ f"No stored product type for {credentials.device_address} - run 'scan' near the "
220
+ "drive first (that's all it takes, no re-registration needed), then retry."
221
+ )
222
+ table = menu_table_for_product(device_info.product_class, device_info.product_id)
223
+ if table is None:
224
+ raise SystemExit(
225
+ f"No menu table known for {device_info.product_name or 'this product'} "
226
+ f"(class={device_info.product_class}, id={device_info.product_id})."
227
+ )
228
+ return table
229
+
230
+
231
+ async def cmd_menu_get(args) -> None:
232
+ credentials = _load_credentials(args)
233
+ address = args.address or credentials.device_address
234
+ table = _resolve_menu_table(args, credentials)
235
+ menu_numbers = [int(n) for n in args.menu_numbers] or None
236
+ menu_groups = [_menu_group_or_exit(table, n) for n in menu_numbers] if menu_numbers else None
237
+
238
+ async with HoermannClient(BleakTransport(address, adapter=args.adapter, on_log=log), on_log=log) as client:
239
+ log(f"Connected to {address}, waiting for the initial notification (challenge)...")
240
+ try:
241
+ await client.wait_for_any_notification(timeout=10.0)
242
+ except asyncio.TimeoutError:
243
+ log("WARNING: no initial notification, challenge may be stale")
244
+
245
+ log(f"Reading properties from {table.product_name} (this can take a while for the full table)...")
246
+ values = await client.read_properties(credentials, menu_groups=menu_groups, timeout=20.0)
247
+
248
+ for menu_group, value in sorted(values.items()):
249
+ setting = menu_setting_for_wire_group(table.settings, menu_group)
250
+ if setting is None:
251
+ print(f"[wire group {menu_group}, not in the {table.product_name} table] = {value}")
252
+ continue
253
+ parameter = next((p for p in setting.parameters if p.value == value), None)
254
+ text = f" ({parameter.text})" if parameter else ""
255
+ label = setting.label_en or setting.label
256
+ print(f"menu {setting.menu_number:>3} {label}: {value}{text}")
257
+
258
+
259
+ async def cmd_menu_set(args) -> None:
260
+ credentials = _load_credentials(args)
261
+ address = args.address or credentials.device_address
262
+ table = _resolve_menu_table(args, credentials)
263
+
264
+ settings = {}
265
+ for item in args.settings:
266
+ menu_str, sep, value_str = item.partition("=")
267
+ if not sep:
268
+ raise SystemExit(f"Expected <menu_number>=<value>, got '{item}'")
269
+ settings[_menu_group_or_exit(table, int(menu_str))] = int(value_str)
270
+
271
+ async with HoermannClient(BleakTransport(address, adapter=args.adapter, on_log=log), on_log=log) as client:
272
+ log(f"Connected to {address}, waiting for the initial notification (challenge)...")
273
+ try:
274
+ await client.wait_for_any_notification(timeout=10.0)
275
+ except asyncio.TimeoutError:
276
+ log("WARNING: no initial notification, challenge may be stale")
277
+
278
+ log(f"Writing {len(settings)} setting(s) to {table.product_name}...")
279
+ try:
280
+ await client.write_properties(credentials, settings, timeout=20.0)
281
+ except PropertiesRejected as exc:
282
+ log(f"ERROR: {exc}")
283
+ raise SystemExit(1)
284
+
285
+ log("Done.")
286
+
287
+
288
+ async def cmd_list_devices(args) -> None:
289
+ """Lists every drive we know the product type of (from 'scan' and/or 'register') -
290
+ 'registered' below means we also hold credentials for it (see 'register'), not just
291
+ that we've seen its advertisement."""
292
+ infos = list_device_infos(config_dir=args.config_dir)
293
+ if not infos:
294
+ print("No known drives yet - run 'scan' near a drive first.")
295
+ return
296
+ for info in infos:
297
+ serial = f" serial={info.serial_no}" if info.serial_no is not None else ""
298
+ registered = "registered" if default_credentials_path(info.device_address, args.config_dir).exists() \
299
+ else "not registered (no credentials)"
300
+ print(f"{info.device_address} {info.product_name or '?'} "
301
+ f"(class={info.product_class}, id={info.product_id}){serial} [{registered}]")
302
+
303
+
304
+ async def cmd_view_log(args) -> None:
305
+ credentials = _load_credentials(args)
306
+ address = args.address or credentials.device_address
307
+
308
+ async with HoermannClient(BleakTransport(address, adapter=args.adapter, on_log=log), on_log=log) as client:
309
+ log(f"Connected to {address}, waiting for the initial notification (challenge)...")
310
+ try:
311
+ await client.wait_for_any_notification(timeout=10.0)
312
+ except asyncio.TimeoutError:
313
+ log("WARNING: no initial notification, challenge may be stale")
314
+
315
+ log("Reading service/diagnostics counters...")
316
+ service_data = await client.read_service_data(credentials, timeout=20.0)
317
+
318
+ log("Reading audit log...")
319
+ log_entries = await client.read_log(credentials, timeout=20.0)
320
+
321
+ print("\n=== Service/diagnostics counters ===")
322
+ if not service_data:
323
+ print("(none)")
324
+ for service_type, value in sorted(service_data.items()):
325
+ name = SERVICE_TYPE_NAMES.get(service_type, f"unknown service type {service_type}")
326
+ if value == 0xFFFFFFFF:
327
+ print(f"{name}: undefined")
328
+ elif service_type in SERVICE_TYPE_IS_TIMESTAMP:
329
+ print(f"{name}: raw={value} (proprietary Hoermann timestamp encoding, not decoded)")
330
+ else:
331
+ print(f"{name}: {value}")
332
+
333
+ print("\n=== Audit log ===")
334
+ if not log_entries:
335
+ print("(none)")
336
+ for log_tag, timestamp_raw, data in log_entries:
337
+ tag_name = LOG_TAG_NAMES.get(log_tag, f"unknown log tag {log_tag}")
338
+ timestamp = log_timestamp_to_datetime(timestamp_raw)
339
+ fields = parse_log_fields(log_tag, data)
340
+ fields_str = ", ".join(f"{key}={value}" for key, value in fields.items())
341
+ suffix = f" ({fields_str})" if fields_str else ""
342
+ print(f"{timestamp:%Y-%m-%d %H:%M:%S} {tag_name}{suffix}")
343
+
344
+
345
+ class _HelpFormatter(argparse.RawDescriptionHelpFormatter):
346
+ """Wider than argparse's default so the example invocations in the
347
+ subcommand list (see `help=` below) don't get truncated."""
348
+
349
+ def __init__(self, prog):
350
+ super().__init__(prog, max_help_position=52, width=100)
351
+
352
+
353
+ def main() -> None:
354
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=_HelpFormatter)
355
+ ap.add_argument("--adapter", default=None,
356
+ help="BlueZ adapter, e.g. hci1. Default: system default.")
357
+ ap.add_argument("--config-dir", default=None,
358
+ help="Base directory for credentials. Priority: "
359
+ "--config-dir > $HOERMOLES_CONF_DIR > .env > ~/.hoermoles")
360
+ sub = ap.add_subparsers(dest="command", required=True, metavar="command")
361
+
362
+ p_scan = sub.add_parser(
363
+ "scan", formatter_class=_HelpFormatter,
364
+ help="uv run hoermoles-ble scan",
365
+ description="Scan for Hoermann BlueSecur drives (no key needed).")
366
+ p_scan.add_argument("--timeout", type=float, default=8.0, help="Scan duration in seconds (default: 8)")
367
+ p_scan.set_defaults(func=cmd_scan)
368
+
369
+ p_qr = sub.add_parser(
370
+ "save-qr", formatter_class=_HelpFormatter,
371
+ help='uv run hoermoles-ble save-qr "<QR code content>"',
372
+ description="Remember a QR code's content (for 'register'/'scan' without --qr-file).")
373
+ p_qr.add_argument("content", help="Full QR code content as text")
374
+ p_qr.set_defaults(func=cmd_save_qr)
375
+
376
+ p_reg = sub.add_parser(
377
+ "register", formatter_class=_HelpFormatter,
378
+ help="uv run hoermoles-ble register --address <MAC>",
379
+ description="Perform the one-time QR code registration.")
380
+ p_reg.add_argument("--address", required=True, help="BLE MAC address of the drive")
381
+ p_reg.add_argument("--qr-file", default=None,
382
+ help="Text file containing the QR code content. Default: look up a matching "
383
+ "saved QR code via scan (see save-qr)")
384
+ p_reg.add_argument("--timeout", type=float, default=8.0,
385
+ help="Scan duration in seconds to find the matching QR code, "
386
+ "only relevant without --qr-file (default: 8)")
387
+ p_reg.add_argument("--key-file", default=None,
388
+ help="Target file for the credentials (JSON). Default: ~/.hoermoles/credentials/<address>.json")
389
+ p_reg.set_defaults(func=cmd_register)
390
+
391
+ p_exec = sub.add_parser(
392
+ "exec", formatter_class=_HelpFormatter,
393
+ help="uv run hoermoles-ble exec --address <MAC> open|close|impulse|light|partial|ventilation",
394
+ description="Trigger a named gate action (open, close, impulse, light, partial, ventilation).")
395
+ p_exec.add_argument("--address", default=None, help="BLE MAC address (also used to find the credentials file). Default: the only/first saved credentials")
396
+ p_exec.add_argument("--key-file", default=None,
397
+ help="File with saved credentials (JSON). Default: ~/.hoermoles/credentials/<address>.json")
398
+ p_exec.add_argument("action", choices=list(GATE_ACTIONS), metavar="action",
399
+ help="One of: " + ", ".join(GATE_ACTIONS) + ". 'impulse' is the "
400
+ "factory-default toggle (open/stop/close); 'open'/'close' are direct "
401
+ "direction commands; 'light', 'partial' and 'ventilation' depend on "
402
+ "the device's configuration.")
403
+ p_exec.set_defaults(func=cmd_exec)
404
+
405
+ p_menu_get = sub.add_parser(
406
+ "menu-get", formatter_class=_HelpFormatter,
407
+ help="uv run hoermoles-ble menu-get --address <MAC> [menu_number ...]",
408
+ description="Read operator menu/parameter settings - see hoermoles_ble.menu_settings for the "
409
+ "known products (Supramatic E4 read-verified against real hardware; others are "
410
+ "structurally derived, not yet verified). Without menu numbers, reads the entire "
411
+ "table. Needs the product type in the device registry - run 'scan' near the drive "
412
+ "first if 'list-devices' doesn't show it yet.")
413
+ p_menu_get.add_argument("--address", default=None, help="BLE MAC address (also used to find the credentials file). Default: the only/first saved credentials")
414
+ p_menu_get.add_argument("--key-file", default=None,
415
+ help="File with saved credentials (JSON). Default: ~/.hoermoles/credentials/<address>.json")
416
+ p_menu_get.add_argument("menu_numbers", nargs="*", metavar="menu_number",
417
+ help="Menu number(s) to read (e.g. 25 52). Default: all known menus.")
418
+ p_menu_get.set_defaults(func=cmd_menu_get)
419
+
420
+ p_menu_set = sub.add_parser(
421
+ "menu-set", formatter_class=_HelpFormatter,
422
+ help="uv run hoermoles-ble menu-set --address <MAC> 25=1 52=0",
423
+ description="Write operator menu/parameter settings - see hoermoles_ble.menu_settings for the "
424
+ "known products. Needs the product type in the device registry - run 'scan' near "
425
+ "the drive first if 'list-devices' doesn't show it yet. NOT YET VERIFIED against "
426
+ "real hardware for any product - read the current value first and double check "
427
+ "against the printed manual.")
428
+ p_menu_set.add_argument("--address", default=None, help="BLE MAC address (also used to find the credentials file). Default: the only/first saved credentials")
429
+ p_menu_set.add_argument("--key-file", default=None,
430
+ help="File with saved credentials (JSON). Default: ~/.hoermoles/credentials/<address>.json")
431
+ p_menu_set.add_argument("settings", nargs="+", metavar="menu_number=value",
432
+ help="One or more <menu_number>=<value> pairs, e.g. 25=1 52=0")
433
+ p_menu_set.set_defaults(func=cmd_menu_set)
434
+
435
+ p_list_devices = sub.add_parser(
436
+ "list-devices", formatter_class=_HelpFormatter,
437
+ help="uv run hoermoles-ble list-devices",
438
+ description="List every drive whose product type we know (from 'scan' and/or 'register') "
439
+ "and whether we also hold credentials for it ('registered').")
440
+ p_list_devices.set_defaults(func=cmd_list_devices)
441
+
442
+ p_view_log = sub.add_parser(
443
+ "view-log", formatter_class=_HelpFormatter,
444
+ help="uv run hoermoles-ble view-log --address <MAC>",
445
+ description="Read the drive's security/access audit log (admin/user actions, "
446
+ "blocked attempts, clock changes) and service/diagnostics counters "
447
+ "(operating hours, door cycles, ...) - see hoermoles_ble.device_log. "
448
+ "Live-verified against a real Supramatic E4.")
449
+ p_view_log.add_argument("--address", default=None, help="BLE MAC address (also used to find the credentials file). Default: the only/first saved credentials")
450
+ p_view_log.add_argument("--key-file", default=None,
451
+ help="File with saved credentials (JSON). Default: ~/.hoermoles/credentials/<address>.json")
452
+ p_view_log.set_defaults(func=cmd_view_log)
453
+
454
+ args = ap.parse_args()
455
+ asyncio.run(args.func(args))
456
+
457
+
458
+ if __name__ == "__main__":
459
+ main()