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 }
|
|
File without changes
|
|
@@ -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()
|