hoermoles-ble-cli 0.2.0__tar.gz → 0.3.2__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.
@@ -6,6 +6,9 @@ __pycache__/
6
6
  *.pyc
7
7
  .venv/
8
8
  *.egg-info/
9
+ .coverage
10
+ coverage.xml
11
+ .pytest_cache/
9
12
 
10
13
  # Local configuration (incl. HOERMOLES_CONF_DIR for development)
11
14
  .env
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hoermoles-ble-cli
3
- Version: 0.2.0
3
+ Version: 0.3.2
4
4
  Summary: CLI test tool for hoermoles-ble (registration, channel control, scan)
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: hoermoles-ble[bleak]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hoermoles-ble-cli"
3
- version = "0.2.0"
3
+ version = "0.3.2"
4
4
  description = "CLI test tool for hoermoles-ble (registration, channel control, scan)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -27,23 +27,26 @@ Examples (after `uv sync` in the workspace root python/):
27
27
 
28
28
  uv run hoermoles-ble view-log --address F1:26:AF:CC:41:86
29
29
  """
30
+
30
31
  import argparse
31
32
  import asyncio
32
33
  import time
33
34
 
34
35
  from hoermoles_ble import (
36
+ GATE_ACTIONS,
37
+ LOG_TAG_NAMES,
38
+ SERVICE_TYPE_IS_TIMESTAMP,
39
+ SERVICE_TYPE_NAMES,
35
40
  Credentials,
36
41
  DeviceInfo,
37
42
  DriveMenuTable,
38
43
  HoermannClient,
39
- LOG_TAG_NAMES,
40
44
  PropertiesRejected,
41
45
  RegistrationTimeout,
42
- GATE_ACTIONS,
43
- SERVICE_TYPE_IS_TIMESTAMP,
44
- SERVICE_TYPE_NAMES,
45
46
  default_credentials_path,
47
+ find_qr_for_address,
46
48
  get_device_info,
49
+ known_qr_serial_map,
47
50
  list_device_infos,
48
51
  log_timestamp_to_datetime,
49
52
  menu_setting_for_wire_group,
@@ -51,10 +54,8 @@ from hoermoles_ble import (
51
54
  parse_log_fields,
52
55
  product_class_and_id_from_qr_prefix,
53
56
  save_device_info,
54
- scan_devices,
55
- find_qr_for_address,
56
57
  save_qr,
57
- known_qr_serial_map,
58
+ scan_devices,
58
59
  serial_no_from_qr_prefix,
59
60
  wire_group_for_menu_number,
60
61
  )
@@ -78,12 +79,12 @@ async def cmd_scan(args) -> None:
78
79
  for info in devices:
79
80
  print(f"\n{info.address} (RSSI {info.rssi} dBm)")
80
81
  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)
82
+ device_info = DeviceInfo(
83
+ info.address, info.product_class, info.product_id, info.product_name, info.serial_no
84
+ )
83
85
  save_device_info(device_info, config_dir=args.config_dir)
84
86
  if info.product_name:
85
- print(f" Product: {info.product_name} "
86
- f"(class={info.product_class}, id={info.product_id})")
87
+ print(f" Product: {info.product_name} (class={info.product_class}, id={info.product_id})")
87
88
  if info.serial_no is not None:
88
89
  qr_status = "yes" if info.serial_no in known else f"no (0 of {len(known)} known QR codes match)"
89
90
  print(f" QR code known: {qr_status}")
@@ -118,10 +119,13 @@ async def cmd_register(args) -> None:
118
119
  with open(args.qr_file, "r") as f:
119
120
  qr_text = f.read()
120
121
  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)
122
+ log(
123
+ f"No --qr-file given, looking for a matching saved QR code for {args.address} "
124
+ f"(scanning for up to {args.timeout:.0f}s)..."
125
+ )
126
+ qr_text = await find_qr_for_address(
127
+ args.address, timeout=args.timeout, adapter=args.adapter, config_dir=args.config_dir
128
+ )
125
129
  if qr_text is None:
126
130
  raise SystemExit(
127
131
  f"No saved QR code matches {args.address} (or the device wasn't found) - "
@@ -151,16 +155,22 @@ async def cmd_register(args) -> None:
151
155
  product_info = product_class_and_id_from_qr_prefix(prefix)
152
156
  if product_info is not None:
153
157
  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
+ product_name = PRODUCT_TYPE_NAMES.get((product_class, product_id)) or PRODUCT_TYPE_NAMES.get(
159
+ (product_class, None)
160
+ )
161
+ device_info = DeviceInfo(
162
+ args.address, product_class, product_id, product_name, serial_no_from_qr_prefix(prefix)
163
+ )
158
164
  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}")
165
+ log(
166
+ f"Product type: {product_name or 'unknown'} (class={product_class}, id={product_id}), "
167
+ f"saved to {devices_path}"
168
+ )
161
169
  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.")
170
+ log(
171
+ "Could not determine the product type from the QR code prefix (non-version-3 layout?) - "
172
+ "run 'scan' near the drive to detect it instead."
173
+ )
164
174
 
165
175
 
166
176
  def _load_credentials(args) -> Credentials:
@@ -295,10 +305,15 @@ async def cmd_list_devices(args) -> None:
295
305
  return
296
306
  for info in infos:
297
307
  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() \
308
+ registered = (
309
+ "registered"
310
+ if default_credentials_path(info.device_address, args.config_dir).exists()
299
311
  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}]")
312
+ )
313
+ print(
314
+ f"{info.device_address} {info.product_name or '?'} "
315
+ f"(class={info.product_class}, id={info.product_id}){serial} [{registered}]"
316
+ )
302
317
 
303
318
 
304
319
  async def cmd_view_log(args) -> None:
@@ -352,103 +367,169 @@ class _HelpFormatter(argparse.RawDescriptionHelpFormatter):
352
367
 
353
368
  def main() -> None:
354
369
  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")
370
+ ap.add_argument("--adapter", default=None, help="BlueZ adapter, e.g. hci1. Default: system default.")
371
+ ap.add_argument(
372
+ "--config-dir",
373
+ default=None,
374
+ help="Base directory for credentials. Priority: --config-dir > $HOERMOLES_CONF_DIR > .env > ~/.hoermoles",
375
+ )
360
376
  sub = ap.add_subparsers(dest="command", required=True, metavar="command")
361
377
 
362
378
  p_scan = sub.add_parser(
363
- "scan", formatter_class=_HelpFormatter,
379
+ "scan",
380
+ formatter_class=_HelpFormatter,
364
381
  help="uv run hoermoles-ble scan",
365
- description="Scan for Hoermann BlueSecur drives (no key needed).")
382
+ description="Scan for Hoermann BlueSecur drives (no key needed).",
383
+ )
366
384
  p_scan.add_argument("--timeout", type=float, default=8.0, help="Scan duration in seconds (default: 8)")
367
385
  p_scan.set_defaults(func=cmd_scan)
368
386
 
369
387
  p_qr = sub.add_parser(
370
- "save-qr", formatter_class=_HelpFormatter,
388
+ "save-qr",
389
+ formatter_class=_HelpFormatter,
371
390
  help='uv run hoermoles-ble save-qr "<QR code content>"',
372
- description="Remember a QR code's content (for 'register'/'scan' without --qr-file).")
391
+ description="Remember a QR code's content (for 'register'/'scan' without --qr-file).",
392
+ )
373
393
  p_qr.add_argument("content", help="Full QR code content as text")
374
394
  p_qr.set_defaults(func=cmd_save_qr)
375
395
 
376
396
  p_reg = sub.add_parser(
377
- "register", formatter_class=_HelpFormatter,
397
+ "register",
398
+ formatter_class=_HelpFormatter,
378
399
  help="uv run hoermoles-ble register --address <MAC>",
379
- description="Perform the one-time QR code registration.")
400
+ description="Perform the one-time QR code registration.",
401
+ )
380
402
  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")
403
+ p_reg.add_argument(
404
+ "--qr-file",
405
+ default=None,
406
+ help="Text file containing the QR code content. Default: look up a matching "
407
+ "saved QR code via scan (see save-qr)",
408
+ )
409
+ p_reg.add_argument(
410
+ "--timeout",
411
+ type=float,
412
+ default=8.0,
413
+ help="Scan duration in seconds to find the matching QR code, only relevant without --qr-file (default: 8)",
414
+ )
415
+ p_reg.add_argument(
416
+ "--key-file",
417
+ default=None,
418
+ help="Target file for the credentials (JSON). Default: ~/.hoermoles/credentials/<address>.json",
419
+ )
389
420
  p_reg.set_defaults(func=cmd_register)
390
421
 
391
422
  p_exec = sub.add_parser(
392
- "exec", formatter_class=_HelpFormatter,
423
+ "exec",
424
+ formatter_class=_HelpFormatter,
393
425
  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.")
426
+ description="Trigger a named gate action (open, close, impulse, light, partial, ventilation).",
427
+ )
428
+ p_exec.add_argument(
429
+ "--address",
430
+ default=None,
431
+ help="BLE MAC address (also used to find the credentials file). Default: the only/first saved credentials",
432
+ )
433
+ p_exec.add_argument(
434
+ "--key-file",
435
+ default=None,
436
+ help="File with saved credentials (JSON). Default: ~/.hoermoles/credentials/<address>.json",
437
+ )
438
+ p_exec.add_argument(
439
+ "action",
440
+ choices=list(GATE_ACTIONS),
441
+ metavar="action",
442
+ help="One of: " + ", ".join(GATE_ACTIONS) + ". 'impulse' is the "
443
+ "factory-default toggle (open/stop/close); 'open'/'close' are direct "
444
+ "direction commands; 'light', 'partial' and 'ventilation' depend on "
445
+ "the device's configuration.",
446
+ )
403
447
  p_exec.set_defaults(func=cmd_exec)
404
448
 
405
449
  p_menu_get = sub.add_parser(
406
- "menu-get", formatter_class=_HelpFormatter,
450
+ "menu-get",
451
+ formatter_class=_HelpFormatter,
407
452
  help="uv run hoermoles-ble menu-get --address <MAC> [menu_number ...]",
408
453
  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.")
454
+ "known products (Supramatic E4 read-verified against real hardware; others are "
455
+ "structurally derived, not yet verified). Without menu numbers, reads the entire "
456
+ "table. Needs the product type in the device registry - run 'scan' near the drive "
457
+ "first if 'list-devices' doesn't show it yet.",
458
+ )
459
+ p_menu_get.add_argument(
460
+ "--address",
461
+ default=None,
462
+ help="BLE MAC address (also used to find the credentials file). Default: the only/first saved credentials",
463
+ )
464
+ p_menu_get.add_argument(
465
+ "--key-file",
466
+ default=None,
467
+ help="File with saved credentials (JSON). Default: ~/.hoermoles/credentials/<address>.json",
468
+ )
469
+ p_menu_get.add_argument(
470
+ "menu_numbers",
471
+ nargs="*",
472
+ metavar="menu_number",
473
+ help="Menu number(s) to read (e.g. 25 52). Default: all known menus.",
474
+ )
418
475
  p_menu_get.set_defaults(func=cmd_menu_get)
419
476
 
420
477
  p_menu_set = sub.add_parser(
421
- "menu-set", formatter_class=_HelpFormatter,
478
+ "menu-set",
479
+ formatter_class=_HelpFormatter,
422
480
  help="uv run hoermoles-ble menu-set --address <MAC> 25=1 52=0",
423
481
  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")
482
+ "known products. Needs the product type in the device registry - run 'scan' near "
483
+ "the drive first if 'list-devices' doesn't show it yet. NOT YET VERIFIED against "
484
+ "real hardware for any product - read the current value first and double check "
485
+ "against the printed manual.",
486
+ )
487
+ p_menu_set.add_argument(
488
+ "--address",
489
+ default=None,
490
+ help="BLE MAC address (also used to find the credentials file). Default: the only/first saved credentials",
491
+ )
492
+ p_menu_set.add_argument(
493
+ "--key-file",
494
+ default=None,
495
+ help="File with saved credentials (JSON). Default: ~/.hoermoles/credentials/<address>.json",
496
+ )
497
+ p_menu_set.add_argument(
498
+ "settings",
499
+ nargs="+",
500
+ metavar="menu_number=value",
501
+ help="One or more <menu_number>=<value> pairs, e.g. 25=1 52=0",
502
+ )
433
503
  p_menu_set.set_defaults(func=cmd_menu_set)
434
504
 
435
505
  p_list_devices = sub.add_parser(
436
- "list-devices", formatter_class=_HelpFormatter,
506
+ "list-devices",
507
+ formatter_class=_HelpFormatter,
437
508
  help="uv run hoermoles-ble list-devices",
438
509
  description="List every drive whose product type we know (from 'scan' and/or 'register') "
439
- "and whether we also hold credentials for it ('registered').")
510
+ "and whether we also hold credentials for it ('registered').",
511
+ )
440
512
  p_list_devices.set_defaults(func=cmd_list_devices)
441
513
 
442
514
  p_view_log = sub.add_parser(
443
- "view-log", formatter_class=_HelpFormatter,
515
+ "view-log",
516
+ formatter_class=_HelpFormatter,
444
517
  help="uv run hoermoles-ble view-log --address <MAC>",
445
518
  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")
519
+ "blocked attempts, clock changes) and service/diagnostics counters "
520
+ "(operating hours, door cycles, ...) - see hoermoles_ble.device_log. "
521
+ "Live-verified against a real Supramatic E4.",
522
+ )
523
+ p_view_log.add_argument(
524
+ "--address",
525
+ default=None,
526
+ help="BLE MAC address (also used to find the credentials file). Default: the only/first saved credentials",
527
+ )
528
+ p_view_log.add_argument(
529
+ "--key-file",
530
+ default=None,
531
+ help="File with saved credentials (JSON). Default: ~/.hoermoles/credentials/<address>.json",
532
+ )
452
533
  p_view_log.set_defaults(func=cmd_view_log)
453
534
 
454
535
  args = ap.parse_args()
@@ -0,0 +1,90 @@
1
+ from types import SimpleNamespace
2
+
3
+ import pytest
4
+ from hoermoles_ble import devices, menu_settings
5
+ from hoermoles_ble.credentials import Credentials
6
+
7
+ from hoermoles_ble_cli.cli import _load_credentials, _menu_group_or_exit, _resolve_menu_table
8
+
9
+
10
+ def _args(**kwargs):
11
+ defaults = {"key_file": None, "address": None, "config_dir": None}
12
+ defaults.update(kwargs)
13
+ return SimpleNamespace(**defaults)
14
+
15
+
16
+ # --- _load_credentials ---------------------------------------------------
17
+
18
+
19
+ def test_load_credentials_from_key_file(tmp_path):
20
+ creds = Credentials(device_address="AA:BB:CC:DD:EE:FF", root_id=1, root_key=b"\x00" * 32)
21
+ path = creds.save(config_dir=tmp_path)
22
+
23
+ loaded = _load_credentials(_args(key_file=str(path)))
24
+ assert loaded.device_address == "AA:BB:CC:DD:EE:FF"
25
+
26
+
27
+ def test_load_credentials_from_address(tmp_path):
28
+ creds = Credentials(device_address="AA:BB:CC:DD:EE:FF", root_id=2, root_key=b"\x01" * 32)
29
+ creds.save(config_dir=tmp_path)
30
+
31
+ loaded = _load_credentials(_args(address="AA:BB:CC:DD:EE:FF", config_dir=tmp_path))
32
+ assert loaded.root_id == 2
33
+
34
+
35
+ def test_load_credentials_falls_back_to_first(tmp_path):
36
+ creds = Credentials(device_address="AA:BB:CC:DD:EE:FF", root_id=3, root_key=b"\x02" * 32)
37
+ creds.save(config_dir=tmp_path)
38
+
39
+ loaded = _load_credentials(_args(config_dir=tmp_path))
40
+ assert loaded.root_id == 3
41
+
42
+
43
+ def test_load_credentials_exits_when_none_saved(tmp_path):
44
+ with pytest.raises(SystemExit):
45
+ _load_credentials(_args(config_dir=tmp_path))
46
+
47
+
48
+ # --- _menu_group_or_exit ---------------------------------------------------
49
+
50
+
51
+ def test_menu_group_or_exit_found():
52
+ table = menu_settings.DRIVE_MENU_TABLES[0]
53
+ setting = table.settings[0]
54
+ assert _menu_group_or_exit(table, setting.menu_number) == setting.menu_group
55
+
56
+
57
+ def test_menu_group_or_exit_unknown_menu_number_exits():
58
+ table = menu_settings.DRIVE_MENU_TABLES[0]
59
+ with pytest.raises(SystemExit):
60
+ _menu_group_or_exit(table, 999999)
61
+
62
+
63
+ # --- _resolve_menu_table ---------------------------------------------------
64
+
65
+
66
+ def test_resolve_menu_table_success(tmp_path):
67
+ creds = Credentials(device_address="F1:26:AF:CC:41:86", root_id=1, root_key=b"\x00" * 32)
68
+ devices.save_device_info(
69
+ devices.DeviceInfo(device_address=creds.device_address, product_class=2, product_id=2),
70
+ config_dir=tmp_path,
71
+ )
72
+
73
+ table = _resolve_menu_table(_args(config_dir=tmp_path), creds)
74
+ assert table.settings == menu_settings.SUPRAMATIC_E4_MENU_TABLE
75
+
76
+
77
+ def test_resolve_menu_table_exits_when_device_unknown(tmp_path):
78
+ creds = Credentials(device_address="AA:BB:CC:DD:EE:FF", root_id=1, root_key=b"\x00" * 32)
79
+ with pytest.raises(SystemExit):
80
+ _resolve_menu_table(_args(config_dir=tmp_path), creds)
81
+
82
+
83
+ def test_resolve_menu_table_exits_when_product_unknown(tmp_path):
84
+ creds = Credentials(device_address="AA:BB:CC:DD:EE:FF", root_id=1, root_key=b"\x00" * 32)
85
+ devices.save_device_info(
86
+ devices.DeviceInfo(device_address=creds.device_address, product_class=99, product_id=99),
87
+ config_dir=tmp_path,
88
+ )
89
+ with pytest.raises(SystemExit):
90
+ _resolve_menu_table(_args(config_dir=tmp_path), creds)