hoermoles-ble 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.
- hoermoles_ble-0.2.0/.gitignore +13 -0
- hoermoles_ble-0.2.0/PKG-INFO +154 -0
- hoermoles_ble-0.2.0/README.md +143 -0
- hoermoles_ble-0.2.0/pyproject.toml +20 -0
- hoermoles_ble-0.2.0/src/hoermoles_ble/__init__.py +91 -0
- hoermoles_ble-0.2.0/src/hoermoles_ble/advertisement.py +162 -0
- hoermoles_ble-0.2.0/src/hoermoles_ble/ble_transport.py +50 -0
- hoermoles_ble-0.2.0/src/hoermoles_ble/client.py +314 -0
- hoermoles_ble-0.2.0/src/hoermoles_ble/config.py +40 -0
- hoermoles_ble-0.2.0/src/hoermoles_ble/credentials.py +91 -0
- hoermoles_ble-0.2.0/src/hoermoles_ble/crypto_rsa.py +23 -0
- hoermoles_ble-0.2.0/src/hoermoles_ble/device_log.py +247 -0
- hoermoles_ble-0.2.0/src/hoermoles_ble/devices.py +86 -0
- hoermoles_ble-0.2.0/src/hoermoles_ble/discovery.py +66 -0
- hoermoles_ble-0.2.0/src/hoermoles_ble/menu_settings.py +555 -0
- hoermoles_ble-0.2.0/src/hoermoles_ble/protocol.py +467 -0
- hoermoles_ble-0.2.0/src/hoermoles_ble/qr_store.py +68 -0
- hoermoles_ble-0.2.0/src/hoermoles_ble/transport.py +42 -0
|
@@ -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,154 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hoermoles-ble
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Portable protocol/crypto core + BLE client for the Hoermann BlueSecur 'Signed' channel (reverse-engineered)
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: cryptography>=42
|
|
7
|
+
Requires-Dist: python-dotenv>=1.0
|
|
8
|
+
Provides-Extra: bleak
|
|
9
|
+
Requires-Dist: bleak>=0.21; extra == 'bleak'
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# hoermoles-ble
|
|
13
|
+
|
|
14
|
+
## Getting started: commissioning a drive
|
|
15
|
+
|
|
16
|
+
Everyday use is via the `hoermoles-ble` CLI (published separately as package
|
|
17
|
+
`hoermoles-ble-cli`, built on top of this library):
|
|
18
|
+
|
|
19
|
+
1. Install the CLI as a standalone tool - this gives you the `hoermoles-ble`
|
|
20
|
+
command on your `$PATH`, no workspace checkout needed:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
uv tool install hoermoles-ble-cli
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
(Contributing to/developing this repo instead? Use `cd python && uv sync`
|
|
27
|
+
and prefix every command below with `uv run` instead.)
|
|
28
|
+
|
|
29
|
+
2. Get the QR code sticker's content onto disk - there's no camera-scanning
|
|
30
|
+
app yet, so either read it with a separate scanner app, or take a photo of
|
|
31
|
+
the sticker and decode it with a command-line QR decoder, e.g.
|
|
32
|
+
[`zbarimg`](https://github.com/mchehab/zbar) (`zbarimg --raw photo.jpg`).
|
|
33
|
+
Then save the decoded content once:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
hoermoles-ble save-qr "<QR code content>"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
3. Scan for the drive over BLE - matches the saved QR code by serial number
|
|
40
|
+
and saves the drive's product type to the device registry (see below):
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
hoermoles-ble scan
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Seeing only the short packet ("incompletely parsed", no `Admin taught:
|
|
47
|
+
...` line) is normal and not a sign of anything - both our own drive
|
|
48
|
+
(already paired) and the official app's own code treat this as a plain
|
|
49
|
+
BLE scan-timing artifact, not something tied to admin/pairing status (the
|
|
50
|
+
decompiled app has explicit, unconditional logic to recover once the
|
|
51
|
+
longer packet eventually arrives - see the local reveng report). Just use
|
|
52
|
+
a longer `--timeout` or retry `scan`.
|
|
53
|
+
|
|
54
|
+
4. Register (one-time pairing). Only works while the drive still accepts a
|
|
55
|
+
new admin (`AdminsCanBeTeached=True` in the scan output above) - reset via
|
|
56
|
+
the drive's menu 19/parameter 02 first if not (this invalidates the
|
|
57
|
+
existing pairing, e.g. with your phone):
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
hoermoles-ble register --address <MAC>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
This derives and saves the root key under
|
|
64
|
+
`~/.hoermoles/credentials/<MAC>.json` - no QR code, app, or cloud needed
|
|
65
|
+
again afterwards.
|
|
66
|
+
|
|
67
|
+
5. Trigger the gate:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
hoermoles-ble exec --address <MAC> impulse
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
From here on `--address` can usually be omitted (it defaults to the only/
|
|
74
|
+
first saved credentials). See `packages/hoermoles-ble-cli/README.md` for the
|
|
75
|
+
full command reference (`menu-get`/`menu-set` for operator settings,
|
|
76
|
+
`view-log` for the audit log/diagnostics counters, `list-devices`, ...) and
|
|
77
|
+
the workspace-level `python/README.md` for a longer walkthrough.
|
|
78
|
+
|
|
79
|
+
## Library internals
|
|
80
|
+
|
|
81
|
+
Protocol core (`protocol.py`, `crypto_rsa.py`) and BLE client (`client.py`,
|
|
82
|
+
`transport.py`, `ble_transport.py`) for the Hoermann BlueSecur "Signed"
|
|
83
|
+
channel.
|
|
84
|
+
|
|
85
|
+
`protocol.py` deliberately has no third-party dependencies (stdlib only) and
|
|
86
|
+
serves as a template for ports to other languages. `bleak` (for the real BLE
|
|
87
|
+
transport) is an optional extra: `pip install hoermoles-ble[bleak]`.
|
|
88
|
+
|
|
89
|
+
Reverse-engineered from the official Hoermann BlueSecur app; see the module
|
|
90
|
+
docstrings in `protocol.py`, `client.py`, and `advertisement.py` for details
|
|
91
|
+
on where each part of the protocol comes from.
|
|
92
|
+
|
|
93
|
+
## Operator menu/parameter settings
|
|
94
|
+
|
|
95
|
+
Besides the channel commands (open/close/light/...), the drive also exposes
|
|
96
|
+
its classic numbered configuration menus (menu 20 "reversal limit", menu 25
|
|
97
|
+
"operator light", menu 52 "speed door open", ...) over BLE - the same menus
|
|
98
|
+
normally set via the hand-transmitter/display sequence, exposed by the
|
|
99
|
+
official app as "operator settings". `HoermannClient.read_properties()` and
|
|
100
|
+
`.write_properties()` (in `client.py`) read/write these; `menu_settings.py`
|
|
101
|
+
provides the menu-number-to-wire-byte table and valid values per menu for
|
|
102
|
+
every product the official app knows about (Supramatic Serie 4/E4, Rollmatic
|
|
103
|
+
2, SilentDrive 2, Supramatic 4 H4, HET), sourced from the app's own embedded
|
|
104
|
+
menu-concept resources, one `DriveMenuTable` per (product_class, product_id)
|
|
105
|
+
in `DRIVE_MENU_TABLES`. See the module docstring for the exact
|
|
106
|
+
firmware/software scope per product (menu_group wire bytes are NOT
|
|
107
|
+
interchangeable across products or even across ProductIDs that share the
|
|
108
|
+
same advertised name) and the CLI's `menu-get`/`menu-set` commands for a
|
|
109
|
+
ready-to-use example.
|
|
110
|
+
|
|
111
|
+
The read direction (`read_properties()`/GET_PROPERTIES/GET_SELECTED_PROPERTIES)
|
|
112
|
+
is live-verified against a real Supramatic E4 - both a full, unfiltered read
|
|
113
|
+
and filtered multi-menu reads returned plausible values. One live-confirmed
|
|
114
|
+
protocol quirk: a GET_SELECTED_PROPERTIES request that mixes a menu group <
|
|
115
|
+
100 with one >= 100 makes the drive drop the connection instead of
|
|
116
|
+
answering - `read_properties()` avoids this via
|
|
117
|
+
`protocol.batch_menu_groups_for_selected_properties()`. The write direction
|
|
118
|
+
(`write_properties()`/SET_PROPERTIES) is only structurally derived from the
|
|
119
|
+
decompiled app and NOT yet confirmed live for any product - read a value back
|
|
120
|
+
before writing it, and double-check menu numbers/values against the printed
|
|
121
|
+
operator manual.
|
|
122
|
+
|
|
123
|
+
## Device registry (which product a paired drive is)
|
|
124
|
+
|
|
125
|
+
`devices.py` keeps a separate `<config_dir>/devices.json` registry mapping
|
|
126
|
+
each MAC address to its product_class/product_id/product_name (and serial
|
|
127
|
+
number, if known) via `save_device_info()`/`get_device_info()`/
|
|
128
|
+
`list_device_infos()` - deliberately not part of `credentials.py`/
|
|
129
|
+
`Credentials`, since it's non-secret metadata and, unlike a per-device
|
|
130
|
+
credentials file, one file can list every drive ever seen. `scan_devices()`
|
|
131
|
+
itself is read-only (no root key needed, nothing persisted) - the CLI's
|
|
132
|
+
`scan` command is what saves a `DeviceInfo` for each device it finds with a
|
|
133
|
+
decodable product_class/product_id; `register` also saves one directly from
|
|
134
|
+
the QR code prefix. `menu-get`/`menu-set` use the registry to pick the right
|
|
135
|
+
`DriveMenuTable` automatically.
|
|
136
|
+
|
|
137
|
+
## Audit log and diagnostics counters
|
|
138
|
+
|
|
139
|
+
`HoermannClient.read_log()`/`.read_service_data()` (in `client.py`) read the
|
|
140
|
+
same two things the app's "log"/diagnostics view shows: a security/access
|
|
141
|
+
audit log (who registered, which channel got toggled, blocked login
|
|
142
|
+
attempts, clock changes, ...) and service counters (operating hours, door
|
|
143
|
+
cycles, maintenance counters, ...) - see `device_log.py` for the
|
|
144
|
+
`LogTag`/`ServiceType` name tables and per-tag field decoding
|
|
145
|
+
(`parse_log_fields()`). Both are live-verified against a real Supramatic E4 -
|
|
146
|
+
that check is also what caught a transcription error in `SERVICE_TYPE_NAMES`
|
|
147
|
+
(wire byte 17 is `ELEMENTS_COUNTER`, not `ENGINE_RUNTIME` - the
|
|
148
|
+
wire-byte-to-meaning mapping in the decompiled app is an explicit switch
|
|
149
|
+
statement, not the `ServiceType` enum's own declared integer values). A
|
|
150
|
+
handful of `ServiceType` entries carry a proprietary Hoermann fixed-32-day-
|
|
151
|
+
month timestamp encoding instead of a plain counter - NOT decoded here (see
|
|
152
|
+
`SERVICE_TYPE_IS_TIMESTAMP`), only the plain-integer counters (including
|
|
153
|
+
operating hours) are. See the CLI's `view-log` command for a ready-to-use
|
|
154
|
+
example.
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# hoermoles-ble
|
|
2
|
+
|
|
3
|
+
## Getting started: commissioning a drive
|
|
4
|
+
|
|
5
|
+
Everyday use is via the `hoermoles-ble` CLI (published separately as package
|
|
6
|
+
`hoermoles-ble-cli`, built on top of this library):
|
|
7
|
+
|
|
8
|
+
1. Install the CLI as a standalone tool - this gives you the `hoermoles-ble`
|
|
9
|
+
command on your `$PATH`, no workspace checkout needed:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uv tool install hoermoles-ble-cli
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
(Contributing to/developing this repo instead? Use `cd python && uv sync`
|
|
16
|
+
and prefix every command below with `uv run` instead.)
|
|
17
|
+
|
|
18
|
+
2. Get the QR code sticker's content onto disk - there's no camera-scanning
|
|
19
|
+
app yet, so either read it with a separate scanner app, or take a photo of
|
|
20
|
+
the sticker and decode it with a command-line QR decoder, e.g.
|
|
21
|
+
[`zbarimg`](https://github.com/mchehab/zbar) (`zbarimg --raw photo.jpg`).
|
|
22
|
+
Then save the decoded content once:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
hoermoles-ble save-qr "<QR code content>"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
3. Scan for the drive over BLE - matches the saved QR code by serial number
|
|
29
|
+
and saves the drive's product type to the device registry (see below):
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
hoermoles-ble scan
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Seeing only the short packet ("incompletely parsed", no `Admin taught:
|
|
36
|
+
...` line) is normal and not a sign of anything - both our own drive
|
|
37
|
+
(already paired) and the official app's own code treat this as a plain
|
|
38
|
+
BLE scan-timing artifact, not something tied to admin/pairing status (the
|
|
39
|
+
decompiled app has explicit, unconditional logic to recover once the
|
|
40
|
+
longer packet eventually arrives - see the local reveng report). Just use
|
|
41
|
+
a longer `--timeout` or retry `scan`.
|
|
42
|
+
|
|
43
|
+
4. Register (one-time pairing). Only works while the drive still accepts a
|
|
44
|
+
new admin (`AdminsCanBeTeached=True` in the scan output above) - reset via
|
|
45
|
+
the drive's menu 19/parameter 02 first if not (this invalidates the
|
|
46
|
+
existing pairing, e.g. with your phone):
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
hoermoles-ble register --address <MAC>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
This derives and saves the root key under
|
|
53
|
+
`~/.hoermoles/credentials/<MAC>.json` - no QR code, app, or cloud needed
|
|
54
|
+
again afterwards.
|
|
55
|
+
|
|
56
|
+
5. Trigger the gate:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
hoermoles-ble exec --address <MAC> impulse
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
From here on `--address` can usually be omitted (it defaults to the only/
|
|
63
|
+
first saved credentials). See `packages/hoermoles-ble-cli/README.md` for the
|
|
64
|
+
full command reference (`menu-get`/`menu-set` for operator settings,
|
|
65
|
+
`view-log` for the audit log/diagnostics counters, `list-devices`, ...) and
|
|
66
|
+
the workspace-level `python/README.md` for a longer walkthrough.
|
|
67
|
+
|
|
68
|
+
## Library internals
|
|
69
|
+
|
|
70
|
+
Protocol core (`protocol.py`, `crypto_rsa.py`) and BLE client (`client.py`,
|
|
71
|
+
`transport.py`, `ble_transport.py`) for the Hoermann BlueSecur "Signed"
|
|
72
|
+
channel.
|
|
73
|
+
|
|
74
|
+
`protocol.py` deliberately has no third-party dependencies (stdlib only) and
|
|
75
|
+
serves as a template for ports to other languages. `bleak` (for the real BLE
|
|
76
|
+
transport) is an optional extra: `pip install hoermoles-ble[bleak]`.
|
|
77
|
+
|
|
78
|
+
Reverse-engineered from the official Hoermann BlueSecur app; see the module
|
|
79
|
+
docstrings in `protocol.py`, `client.py`, and `advertisement.py` for details
|
|
80
|
+
on where each part of the protocol comes from.
|
|
81
|
+
|
|
82
|
+
## Operator menu/parameter settings
|
|
83
|
+
|
|
84
|
+
Besides the channel commands (open/close/light/...), the drive also exposes
|
|
85
|
+
its classic numbered configuration menus (menu 20 "reversal limit", menu 25
|
|
86
|
+
"operator light", menu 52 "speed door open", ...) over BLE - the same menus
|
|
87
|
+
normally set via the hand-transmitter/display sequence, exposed by the
|
|
88
|
+
official app as "operator settings". `HoermannClient.read_properties()` and
|
|
89
|
+
`.write_properties()` (in `client.py`) read/write these; `menu_settings.py`
|
|
90
|
+
provides the menu-number-to-wire-byte table and valid values per menu for
|
|
91
|
+
every product the official app knows about (Supramatic Serie 4/E4, Rollmatic
|
|
92
|
+
2, SilentDrive 2, Supramatic 4 H4, HET), sourced from the app's own embedded
|
|
93
|
+
menu-concept resources, one `DriveMenuTable` per (product_class, product_id)
|
|
94
|
+
in `DRIVE_MENU_TABLES`. See the module docstring for the exact
|
|
95
|
+
firmware/software scope per product (menu_group wire bytes are NOT
|
|
96
|
+
interchangeable across products or even across ProductIDs that share the
|
|
97
|
+
same advertised name) and the CLI's `menu-get`/`menu-set` commands for a
|
|
98
|
+
ready-to-use example.
|
|
99
|
+
|
|
100
|
+
The read direction (`read_properties()`/GET_PROPERTIES/GET_SELECTED_PROPERTIES)
|
|
101
|
+
is live-verified against a real Supramatic E4 - both a full, unfiltered read
|
|
102
|
+
and filtered multi-menu reads returned plausible values. One live-confirmed
|
|
103
|
+
protocol quirk: a GET_SELECTED_PROPERTIES request that mixes a menu group <
|
|
104
|
+
100 with one >= 100 makes the drive drop the connection instead of
|
|
105
|
+
answering - `read_properties()` avoids this via
|
|
106
|
+
`protocol.batch_menu_groups_for_selected_properties()`. The write direction
|
|
107
|
+
(`write_properties()`/SET_PROPERTIES) is only structurally derived from the
|
|
108
|
+
decompiled app and NOT yet confirmed live for any product - read a value back
|
|
109
|
+
before writing it, and double-check menu numbers/values against the printed
|
|
110
|
+
operator manual.
|
|
111
|
+
|
|
112
|
+
## Device registry (which product a paired drive is)
|
|
113
|
+
|
|
114
|
+
`devices.py` keeps a separate `<config_dir>/devices.json` registry mapping
|
|
115
|
+
each MAC address to its product_class/product_id/product_name (and serial
|
|
116
|
+
number, if known) via `save_device_info()`/`get_device_info()`/
|
|
117
|
+
`list_device_infos()` - deliberately not part of `credentials.py`/
|
|
118
|
+
`Credentials`, since it's non-secret metadata and, unlike a per-device
|
|
119
|
+
credentials file, one file can list every drive ever seen. `scan_devices()`
|
|
120
|
+
itself is read-only (no root key needed, nothing persisted) - the CLI's
|
|
121
|
+
`scan` command is what saves a `DeviceInfo` for each device it finds with a
|
|
122
|
+
decodable product_class/product_id; `register` also saves one directly from
|
|
123
|
+
the QR code prefix. `menu-get`/`menu-set` use the registry to pick the right
|
|
124
|
+
`DriveMenuTable` automatically.
|
|
125
|
+
|
|
126
|
+
## Audit log and diagnostics counters
|
|
127
|
+
|
|
128
|
+
`HoermannClient.read_log()`/`.read_service_data()` (in `client.py`) read the
|
|
129
|
+
same two things the app's "log"/diagnostics view shows: a security/access
|
|
130
|
+
audit log (who registered, which channel got toggled, blocked login
|
|
131
|
+
attempts, clock changes, ...) and service counters (operating hours, door
|
|
132
|
+
cycles, maintenance counters, ...) - see `device_log.py` for the
|
|
133
|
+
`LogTag`/`ServiceType` name tables and per-tag field decoding
|
|
134
|
+
(`parse_log_fields()`). Both are live-verified against a real Supramatic E4 -
|
|
135
|
+
that check is also what caught a transcription error in `SERVICE_TYPE_NAMES`
|
|
136
|
+
(wire byte 17 is `ELEMENTS_COUNTER`, not `ENGINE_RUNTIME` - the
|
|
137
|
+
wire-byte-to-meaning mapping in the decompiled app is an explicit switch
|
|
138
|
+
statement, not the `ServiceType` enum's own declared integer values). A
|
|
139
|
+
handful of `ServiceType` entries carry a proprietary Hoermann fixed-32-day-
|
|
140
|
+
month timestamp encoding instead of a plain counter - NOT decoded here (see
|
|
141
|
+
`SERVICE_TYPE_IS_TIMESTAMP`), only the plain-integer counters (including
|
|
142
|
+
operating hours) are. See the CLI's `view-log` command for a ready-to-use
|
|
143
|
+
example.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "hoermoles-ble"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Portable protocol/crypto core + BLE client for the Hoermann BlueSecur 'Signed' channel (reverse-engineered)"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"cryptography>=42",
|
|
9
|
+
"python-dotenv>=1.0",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.optional-dependencies]
|
|
13
|
+
bleak = ["bleak>=0.21"]
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["hatchling"]
|
|
17
|
+
build-backend = "hatchling.build"
|
|
18
|
+
|
|
19
|
+
[tool.hatch.build.targets.wheel]
|
|
20
|
+
packages = ["src/hoermoles_ble"]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from .advertisement import AdvertisementInfo
|
|
2
|
+
from .client import HoermannClient, PropertiesRejected, RegistrationTimeout
|
|
3
|
+
from .config import ENV_VAR_CONFIG_DIR, resolve_config_dir
|
|
4
|
+
from .credentials import Credentials, default_credentials_path
|
|
5
|
+
from .device_log import (
|
|
6
|
+
LOG_TAG_NAMES,
|
|
7
|
+
SERVICE_TYPE_IS_TIMESTAMP,
|
|
8
|
+
SERVICE_TYPE_NAMES,
|
|
9
|
+
log_timestamp_to_datetime,
|
|
10
|
+
parse_log_fields,
|
|
11
|
+
)
|
|
12
|
+
from .devices import (
|
|
13
|
+
DeviceInfo,
|
|
14
|
+
default_devices_registry_path,
|
|
15
|
+
get_device_info,
|
|
16
|
+
list_device_infos,
|
|
17
|
+
load_device_registry,
|
|
18
|
+
save_device_info,
|
|
19
|
+
)
|
|
20
|
+
from .discovery import find_qr_for_address, scan_devices
|
|
21
|
+
from .menu_settings import (
|
|
22
|
+
DRIVE_MENU_TABLES,
|
|
23
|
+
SUPRAMATIC_E4_MENU_TABLE,
|
|
24
|
+
SUPRAMATIC_E4_PRODUCT_CLASS,
|
|
25
|
+
SUPRAMATIC_E4_PRODUCT_ID,
|
|
26
|
+
DriveMenuTable,
|
|
27
|
+
MenuParameter,
|
|
28
|
+
MenuSetting,
|
|
29
|
+
menu_setting_for_number,
|
|
30
|
+
menu_setting_for_wire_group,
|
|
31
|
+
menu_table_for_product,
|
|
32
|
+
menu_tables_for_product,
|
|
33
|
+
wire_group_for_menu_number,
|
|
34
|
+
)
|
|
35
|
+
from .protocol import (
|
|
36
|
+
BC_RX,
|
|
37
|
+
BC_SERVICE,
|
|
38
|
+
BC_TX,
|
|
39
|
+
CHANNEL_COMMAND_ID,
|
|
40
|
+
GATE_ACTIONS,
|
|
41
|
+
product_class_and_id_from_qr_prefix,
|
|
42
|
+
serial_no_from_qr_prefix,
|
|
43
|
+
)
|
|
44
|
+
from .qr_store import default_qr_store_path, known_qr_serial_map, load_known_qrs, save_qr
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"HoermannClient",
|
|
48
|
+
"RegistrationTimeout",
|
|
49
|
+
"PropertiesRejected",
|
|
50
|
+
"Credentials",
|
|
51
|
+
"default_credentials_path",
|
|
52
|
+
"AdvertisementInfo",
|
|
53
|
+
"scan_devices",
|
|
54
|
+
"find_qr_for_address",
|
|
55
|
+
"resolve_config_dir",
|
|
56
|
+
"ENV_VAR_CONFIG_DIR",
|
|
57
|
+
"save_qr",
|
|
58
|
+
"load_known_qrs",
|
|
59
|
+
"default_qr_store_path",
|
|
60
|
+
"known_qr_serial_map",
|
|
61
|
+
"serial_no_from_qr_prefix",
|
|
62
|
+
"product_class_and_id_from_qr_prefix",
|
|
63
|
+
"BC_SERVICE",
|
|
64
|
+
"BC_TX",
|
|
65
|
+
"BC_RX",
|
|
66
|
+
"CHANNEL_COMMAND_ID",
|
|
67
|
+
"GATE_ACTIONS",
|
|
68
|
+
"MenuSetting",
|
|
69
|
+
"MenuParameter",
|
|
70
|
+
"DriveMenuTable",
|
|
71
|
+
"DRIVE_MENU_TABLES",
|
|
72
|
+
"SUPRAMATIC_E4_MENU_TABLE",
|
|
73
|
+
"SUPRAMATIC_E4_PRODUCT_CLASS",
|
|
74
|
+
"SUPRAMATIC_E4_PRODUCT_ID",
|
|
75
|
+
"menu_setting_for_number",
|
|
76
|
+
"menu_setting_for_wire_group",
|
|
77
|
+
"wire_group_for_menu_number",
|
|
78
|
+
"menu_tables_for_product",
|
|
79
|
+
"menu_table_for_product",
|
|
80
|
+
"DeviceInfo",
|
|
81
|
+
"default_devices_registry_path",
|
|
82
|
+
"load_device_registry",
|
|
83
|
+
"list_device_infos",
|
|
84
|
+
"get_device_info",
|
|
85
|
+
"save_device_info",
|
|
86
|
+
"LOG_TAG_NAMES",
|
|
87
|
+
"SERVICE_TYPE_NAMES",
|
|
88
|
+
"SERVICE_TYPE_IS_TIMESTAMP",
|
|
89
|
+
"parse_log_fields",
|
|
90
|
+
"log_timestamp_to_datetime",
|
|
91
|
+
]
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Passive parsing of the Hoermann BlueSecur BLE advertisement (Manufacturer Specific Data,
|
|
3
|
+
Company ID 0x07B4 = 1972) - provides status info WITHOUT a connection and
|
|
4
|
+
WITHOUT a root key.
|
|
5
|
+
|
|
6
|
+
Reconstructed from SAL.BlueConnect.API.Devices.Info.DeviceData (BLEAdvertisementData.cs,
|
|
7
|
+
BlueSecurAdvertisementService.cs, ServiceProduct.cs, product-specific *AdvertisementService.cs)
|
|
8
|
+
and verified empirically against a real, freshly reset drive: after a reset via
|
|
9
|
+
menu 19/parameter 02, `admins_can_be_teached` reliably flipped from False to
|
|
10
|
+
True - confirming the bit position.
|
|
11
|
+
|
|
12
|
+
Important quirk: the device sends the content for Company ID 1972 across two
|
|
13
|
+
different, alternating manufacturer-data packets (6 and 17 bytes on our test
|
|
14
|
+
device). Only the 2-byte company ID + both payloads concatenated (shortest
|
|
15
|
+
first) yields the byte sequence expected by the original parser -
|
|
16
|
+
`combine_manufacturer_payloads()` reproduces that. A single scan callback
|
|
17
|
+
usually only sees one of the two packets; `scan_devices()` in discovery.py
|
|
18
|
+
therefore collects over the entire scan window.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import struct
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
COMPANY_ID = 1972 # 0x07B4
|
|
27
|
+
|
|
28
|
+
# SAL.BlueConnect.API.Devices.Info.DeviceData.DeviceService.ProductType, incomplete
|
|
29
|
+
# (only the mappings visible in the decompiled BLEAdvertisementData.cs)
|
|
30
|
+
PRODUCT_TYPE_NAMES = {
|
|
31
|
+
(1, None): "HET",
|
|
32
|
+
(2, 1): "Supramatic Serie 4",
|
|
33
|
+
(2, 2): "Supramatic Serie 4",
|
|
34
|
+
(2, 17): "Rollmatic 2",
|
|
35
|
+
(2, 33): "SilentDrive 2",
|
|
36
|
+
(2, 49): "Supramatic 4 H4",
|
|
37
|
+
(3, 1): "ST560", (3, 2): "ST560", (3, 8): "ST560", (3, 40): "ST560",
|
|
38
|
+
(3, 17): "ST560 Dockleveller",
|
|
39
|
+
(3, 33): "ST545",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _bit(b: int, position: int) -> bool:
|
|
44
|
+
"""DeviceData.GetBitFromByte: position is 1-indexed (1 = LSB)."""
|
|
45
|
+
return (b & (1 << (position - 1))) != 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def combine_manufacturer_payloads(payloads: list[bytes]) -> bytes:
|
|
49
|
+
"""2-byte company ID (LE) + the payloads seen, shortest first - see module
|
|
50
|
+
docstring. With only one payload seen, only that one is used; the length
|
|
51
|
+
then usually isn't enough for the full parser (>=17 bytes needed, see
|
|
52
|
+
AdvertisementInfo.parse_error)."""
|
|
53
|
+
ordered = sorted(set(payloads), key=len)
|
|
54
|
+
return COMPANY_ID.to_bytes(2, "little") + b"".join(ordered)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class AdvertisementInfo:
|
|
59
|
+
"""Everything that can be read purely from the advertisement without a
|
|
60
|
+
root key. Fields stay None if not enough raw data was seen, or the field
|
|
61
|
+
isn't defined for the given product type (see parse_error)."""
|
|
62
|
+
|
|
63
|
+
address: str
|
|
64
|
+
rssi: int
|
|
65
|
+
raw_manufacturer_data: list[str] # hex strings, as seen during the scan
|
|
66
|
+
|
|
67
|
+
product_class: Optional[int] = None
|
|
68
|
+
product_id: Optional[int] = None
|
|
69
|
+
product_name: Optional[str] = None
|
|
70
|
+
serial_no: Optional[int] = None
|
|
71
|
+
is_blue_secur: Optional[bool] = None
|
|
72
|
+
clock_time_set: Optional[bool] = None
|
|
73
|
+
protection_active: Optional[bool] = None
|
|
74
|
+
admin_teached: Optional[bool] = None
|
|
75
|
+
admins_can_be_teached: Optional[bool] = None
|
|
76
|
+
|
|
77
|
+
in_action: Optional[bool] = None
|
|
78
|
+
opening_time: Optional[bool] = None
|
|
79
|
+
warning_time: Optional[bool] = None
|
|
80
|
+
emergency_mode: Optional[bool] = None
|
|
81
|
+
low_battery: Optional[bool] = None
|
|
82
|
+
teached_control: Optional[bool] = None
|
|
83
|
+
vacation_mode: Optional[bool] = None
|
|
84
|
+
|
|
85
|
+
relais1_open: Optional[bool] = None
|
|
86
|
+
relais2_open: Optional[bool] = None
|
|
87
|
+
opening_progress_percent: Optional[float] = None
|
|
88
|
+
maintenance_required: Optional[bool] = None
|
|
89
|
+
|
|
90
|
+
parse_error: Optional[str] = None
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def from_scan(cls, address: str, rssi: int, payloads: list[bytes]) -> "AdvertisementInfo":
|
|
94
|
+
info = cls(address=address, rssi=rssi, raw_manufacturer_data=[p.hex() for p in payloads])
|
|
95
|
+
if not payloads:
|
|
96
|
+
return info
|
|
97
|
+
distinct = list({p for p in payloads})
|
|
98
|
+
if len(distinct) < 2:
|
|
99
|
+
# Empirically (see module docstring) the device sends status/product
|
|
100
|
+
# info in two alternating packets - with only one of them the length
|
|
101
|
+
# and "enough bytes present" checks would pass, but the result would
|
|
102
|
+
# be computed from the wrong bytes (e.g. all flags falsely False).
|
|
103
|
+
# Better to honestly parse nothing than to fake a wrong result.
|
|
104
|
+
info.parse_error = (f"only {len(distinct)} distinct manufacturer-data packet(s) "
|
|
105
|
+
"seen, need at least 2 for full parsing - "
|
|
106
|
+
"scan longer (increase --timeout)")
|
|
107
|
+
return info
|
|
108
|
+
try:
|
|
109
|
+
info._parse(combine_manufacturer_payloads(distinct))
|
|
110
|
+
except Exception as exc: # advertisement parsing must never crash a scan
|
|
111
|
+
info.parse_error = f"{type(exc).__name__}: {exc}"
|
|
112
|
+
return info
|
|
113
|
+
|
|
114
|
+
def _parse(self, data: bytes) -> None:
|
|
115
|
+
# BLEAdvertisementData.ParseData
|
|
116
|
+
if len(data) < 4:
|
|
117
|
+
self.parse_error = "not enough raw data for product class/ID"
|
|
118
|
+
return
|
|
119
|
+
product_id, product_class = data[2], data[3]
|
|
120
|
+
self.product_class = product_class
|
|
121
|
+
self.product_id = product_id
|
|
122
|
+
self.product_name = (PRODUCT_TYPE_NAMES.get((product_class, product_id))
|
|
123
|
+
or PRODUCT_TYPE_NAMES.get((product_class, None)))
|
|
124
|
+
|
|
125
|
+
if len(data) < 17:
|
|
126
|
+
self.parse_error = (f"only {len(data)} of the required >=17 bytes seen "
|
|
127
|
+
"(maybe not both advertisement packets were captured during the scan)")
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
status_byte = data[4]
|
|
131
|
+
self.is_blue_secur = _bit(status_byte, 1)
|
|
132
|
+
self.clock_time_set = _bit(status_byte, 3)
|
|
133
|
+
self.admin_teached = _bit(status_byte, 4)
|
|
134
|
+
self.protection_active = _bit(status_byte, 5)
|
|
135
|
+
self.admins_can_be_teached = _bit(status_byte, 6)
|
|
136
|
+
self.in_action = _bit(status_byte, 7)
|
|
137
|
+
self.opening_time = _bit(status_byte, 8)
|
|
138
|
+
|
|
139
|
+
# BlueSecurAdvertisementService/ServiceProduct receive the same 13 bytes from here on
|
|
140
|
+
product_data = data[4:17]
|
|
141
|
+
if len(product_data) >= 2:
|
|
142
|
+
b1 = product_data[1]
|
|
143
|
+
self.warning_time = _bit(b1, 1)
|
|
144
|
+
self.emergency_mode = _bit(b1, 2)
|
|
145
|
+
self.low_battery = _bit(b1, 3)
|
|
146
|
+
self.teached_control = not _bit(b1, 7)
|
|
147
|
+
self.vacation_mode = _bit(b1, 8)
|
|
148
|
+
|
|
149
|
+
if product_class == 1 and len(product_data) >= 1:
|
|
150
|
+
# HetBlueSecurAdvertisementService.ParseData replaces the ServiceProduct base fields entirely
|
|
151
|
+
self.relais1_open = _bit(product_data[0], 1)
|
|
152
|
+
self.relais2_open = _bit(product_data[0], 2)
|
|
153
|
+
elif product_class == 2 and len(product_data) >= 7:
|
|
154
|
+
# SupramaticBlueSecurAdvertisementService.ParseDeviceDependentData
|
|
155
|
+
self.opening_progress_percent = product_data[5] / 2.0
|
|
156
|
+
self.maintenance_required = _bit(product_data[6], 1)
|
|
157
|
+
|
|
158
|
+
# BLEAdvertisementData.ParseData: the serial number follows as a uint64 LE
|
|
159
|
+
# from position 17 (4+13) onward - verified live against the QR code prefix
|
|
160
|
+
# of the same device (see protocol.serial_no_from_qr_prefix).
|
|
161
|
+
if len(data) >= 25:
|
|
162
|
+
self.serial_no = struct.unpack_from("<Q", data, 17)[0]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""bleak-based BleTransport implementation (Linux/BlueZ, macOS, Windows)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Callable, Optional
|
|
5
|
+
|
|
6
|
+
from bleak import BleakClient
|
|
7
|
+
|
|
8
|
+
from .protocol import BC_RX, BC_TX
|
|
9
|
+
from .transport import BleTransport
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BleakTransport(BleTransport):
|
|
13
|
+
def __init__(self, address: str, adapter: Optional[str] = None,
|
|
14
|
+
on_log: Optional[Callable[[str], None]] = None):
|
|
15
|
+
self._address = address
|
|
16
|
+
self._adapter = adapter
|
|
17
|
+
self._on_log = on_log or (lambda msg: None)
|
|
18
|
+
self._client: Optional[BleakClient] = None
|
|
19
|
+
|
|
20
|
+
def _on_disconnected(self, client: BleakClient) -> None:
|
|
21
|
+
self._on_log(f"BLE connection lost (disconnected_callback), was connected={client.is_connected}")
|
|
22
|
+
|
|
23
|
+
async def connect(self) -> None:
|
|
24
|
+
self._client = BleakClient(self._address, adapter=self._adapter,
|
|
25
|
+
disconnected_callback=self._on_disconnected)
|
|
26
|
+
await self._client.connect()
|
|
27
|
+
self._on_log(f"MTU after connecting: {self._client.mtu_size}")
|
|
28
|
+
|
|
29
|
+
async def disconnect(self) -> None:
|
|
30
|
+
if self._client is not None:
|
|
31
|
+
await self._client.disconnect()
|
|
32
|
+
self._client = None
|
|
33
|
+
|
|
34
|
+
async def write(self, data: bytes) -> None:
|
|
35
|
+
assert self._client is not None, "not connected"
|
|
36
|
+
self._on_log(f" write_gatt_char {len(data)} bytes: {data.hex()} "
|
|
37
|
+
f"(is_connected={self._client.is_connected})")
|
|
38
|
+
await self._client.write_gatt_char(BC_TX, data, response=False)
|
|
39
|
+
|
|
40
|
+
async def start_notify(self, callback: Callable[[bytes], None]) -> None:
|
|
41
|
+
assert self._client is not None, "not connected"
|
|
42
|
+
|
|
43
|
+
def _on_notify(_, data: bytearray) -> None:
|
|
44
|
+
callback(bytes(data))
|
|
45
|
+
|
|
46
|
+
await self._client.start_notify(BC_RX, _on_notify)
|
|
47
|
+
|
|
48
|
+
async def stop_notify(self) -> None:
|
|
49
|
+
if self._client is not None:
|
|
50
|
+
await self._client.stop_notify(BC_RX)
|