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.
@@ -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)