hansa-ble-protocol 0.2.0__py3-none-any.whl

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.

Potentially problematic release.


This version of hansa-ble-protocol might be problematic. Click here for more details.

@@ -0,0 +1,99 @@
1
+ """Wire protocol of Hansa/Oras Bluetooth faucets.
2
+
3
+ This package knows how to read and write the bytes; it opens no connections
4
+ and depends on no Bluetooth stack, so it can be tested without hardware.
5
+ """
6
+
7
+ from .const import (
8
+ CH_COMMAND,
9
+ CH_COUNTER_A,
10
+ CH_COUNTER_B,
11
+ CH_COUNTER_C,
12
+ CH_NONCE,
13
+ CH_PARAM_A,
14
+ CH_PARAM_B,
15
+ CH_PARAM_C,
16
+ CH_PASSWORD,
17
+ CH_PRODUCT_INFO,
18
+ CH_PRODUCT_LOCATION,
19
+ CH_PRODUCT_NAME,
20
+ CH_STATE_A,
21
+ CH_STATE_B,
22
+ CMD_CLEANING,
23
+ CMD_COUNTER_RESET,
24
+ CMD_LOG_RESET,
25
+ CMD_NORMAL,
26
+ CMD_OPEN,
27
+ CMD_PAIRING_CLEAR,
28
+ CMD_PAIRING_START,
29
+ CMD_WINK,
30
+ MANUFACTURER,
31
+ MANUFACTURER_ID,
32
+ SERVICE_INFO,
33
+ SERVICE_SETTINGS,
34
+ SERVICE_TESTING,
35
+ )
36
+ from .protocol import (
37
+ PARAM_A_FIELDS,
38
+ FaucetData,
39
+ auth_response,
40
+ is_authenticated,
41
+ parse_advertisement,
42
+ parse_counter_a,
43
+ parse_counter_b,
44
+ parse_counter_c,
45
+ parse_param_a,
46
+ parse_product_info,
47
+ parse_product_location,
48
+ parse_product_name,
49
+ parse_state_a,
50
+ parse_state_b,
51
+ session_key,
52
+ write_param_a,
53
+ )
54
+
55
+ __all__ = [
56
+ "CH_COMMAND",
57
+ "CH_COUNTER_A",
58
+ "CH_COUNTER_B",
59
+ "CH_COUNTER_C",
60
+ "CH_NONCE",
61
+ "CH_PARAM_A",
62
+ "CH_PARAM_B",
63
+ "CH_PARAM_C",
64
+ "CH_PASSWORD",
65
+ "CH_PRODUCT_INFO",
66
+ "CH_PRODUCT_LOCATION",
67
+ "CH_PRODUCT_NAME",
68
+ "CH_STATE_A",
69
+ "CH_STATE_B",
70
+ "CMD_CLEANING",
71
+ "CMD_COUNTER_RESET",
72
+ "CMD_LOG_RESET",
73
+ "CMD_NORMAL",
74
+ "CMD_OPEN",
75
+ "CMD_PAIRING_CLEAR",
76
+ "CMD_PAIRING_START",
77
+ "CMD_WINK",
78
+ "MANUFACTURER",
79
+ "MANUFACTURER_ID",
80
+ "PARAM_A_FIELDS",
81
+ "SERVICE_INFO",
82
+ "SERVICE_SETTINGS",
83
+ "SERVICE_TESTING",
84
+ "FaucetData",
85
+ "auth_response",
86
+ "is_authenticated",
87
+ "parse_advertisement",
88
+ "parse_counter_a",
89
+ "parse_counter_b",
90
+ "parse_counter_c",
91
+ "parse_param_a",
92
+ "parse_product_info",
93
+ "parse_product_location",
94
+ "parse_product_name",
95
+ "parse_state_a",
96
+ "parse_state_b",
97
+ "session_key",
98
+ "write_param_a",
99
+ ]
@@ -0,0 +1,41 @@
1
+ """Constants of the Hansa/Oras wire protocol.
2
+
3
+ Every value here was derived from the vendor app and verified against a real
4
+ device; docs/protocol.md records how each one was established.
5
+ """
6
+
7
+ from typing import Final
8
+
9
+ MANUFACTURER: Final = "Hansa"
10
+ MANUFACTURER_ID: Final = 305 # 0x0131
11
+
12
+ SERVICE_INFO: Final = "2be32db1-5f6b-4cbd-8803-38d6dfb16490"
13
+ SERVICE_SETTINGS: Final = "2be32db1-5f6b-5bd8-8033-8d6dfb164900"
14
+ SERVICE_TESTING: Final = "2be32db1-5f6b-6bd8-8033-8d6dfb164900"
15
+
16
+ # Handles differ per device, so everything is addressed by UUID.
17
+ CH_PRODUCT_INFO: Final = "2be32db1-5f6b-4cbd-8813-8d6dfb164900"
18
+ CH_PRODUCT_NAME: Final = "2be32db1-5f6b-4cbd-8823-8d6dfb164900"
19
+ CH_PRODUCT_LOCATION: Final = "2be32db1-5f6b-4cbd-8833-8d6dfb164900"
20
+ CH_STATE_A: Final = "2be32db1-5f6b-4cbd-8843-8d6dfb164900"
21
+ CH_STATE_B: Final = "2be32db1-5f6b-4cbd-8853-8d6dfb164900"
22
+ CH_COUNTER_A: Final = "2be32db1-5f6b-4cbd-8863-8d6dfb164900"
23
+ CH_COUNTER_B: Final = "2be32db1-5f6b-4cbd-8873-8d6dfb164900"
24
+ CH_COUNTER_C: Final = "2be32db1-5f6b-4cbd-8883-8d6dfb164900"
25
+
26
+ CH_PARAM_A: Final = "2be32db1-5f6b-5bd8-8138-d6dfb1649000"
27
+ CH_PARAM_B: Final = "2be32db1-5f6b-5bd8-8238-d6dfb1649000"
28
+ CH_PARAM_C: Final = "2be32db1-5f6b-5bd8-8338-d6dfb1649000"
29
+ CH_COMMAND: Final = "2be32db1-5f6b-5bd8-8a38-d6dfb1649000"
30
+ CH_PASSWORD: Final = "2be32db1-5f6b-5bd8-8b38-d6dfb1649000"
31
+ CH_NONCE: Final = "2be32db1-5f6b-5bd8-8e8d-6dfb16490000"
32
+
33
+ # A single byte written to CH_COMMAND once authenticated.
34
+ CMD_WINK: Final = 0x51
35
+ CMD_OPEN: Final = 0x62
36
+ CMD_CLEANING: Final = 0x73
37
+ CMD_NORMAL: Final = 0x84
38
+ CMD_LOG_RESET: Final = 0x52
39
+ CMD_COUNTER_RESET: Final = 0xA6
40
+ CMD_PAIRING_START: Final = 0x40
41
+ CMD_PAIRING_CLEAR: Final = 0x42
@@ -0,0 +1,186 @@
1
+ """Wire format of the Hansa/Oras faucet: authentication and decoding.
2
+
3
+ Reconstructed from the vendor app and checked against a real device. All
4
+ integers are little endian, times are seconds, valve open time is in tenths
5
+ of a second and the battery voltage is in hundredths of a volt.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ from dataclasses import dataclass, field
12
+ from typing import Any
13
+
14
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
15
+ from cryptography.hazmat.primitives.ciphers.aead import AESCCM
16
+
17
+ # Fixed bootstrap key taken from the vendor app: sixteen 0x01 bytes.
18
+ _BOOTSTRAP_KEY = bytes([0x01] * 16)
19
+
20
+ # CCM with L=2 leaves 15-L = 13 bytes of nonce.
21
+ _NONCE_LEN = 13
22
+
23
+
24
+ def _ctr_keystream(key: bytes, nonce: bytes, blocks: int) -> bytes:
25
+ """CCM keystream A_1..A_n. A_0 only feeds the tag and is not needed here."""
26
+ cipher = Cipher(algorithms.AES(key), modes.ECB())
27
+ stream = b""
28
+ for counter in range(1, blocks + 1):
29
+ block = bytes([1]) + nonce + counter.to_bytes(2, "big") # flags: L-1 = 1
30
+ encryptor = cipher.encryptor()
31
+ stream += encryptor.update(block) + encryptor.finalize()
32
+ return stream
33
+
34
+
35
+ def session_key(nonce: bytes, password: bytes) -> bytes:
36
+ """Derive the session key from the nonce and the password characteristic.
37
+
38
+ The app nominally decrypts this with AES-CCM, but the tag area of the
39
+ buffer is all zeroes, so an authenticated decrypt could never verify.
40
+ Only the CTR half matters, and that is a plain XOR with the keystream.
41
+ """
42
+ keystream = _ctr_keystream(_BOOTSTRAP_KEY, nonce[:_NONCE_LEN], 1)
43
+ return bytes(a ^ b for a, b in zip(password[:16], keystream, strict=False))
44
+
45
+
46
+ def auth_response(nonce: bytes, password: bytes, pin: str) -> bytes:
47
+ """Build the 16 bytes written back to the password characteristic."""
48
+ ccm = AESCCM(session_key(nonce, password), tag_length=8)
49
+ digest = hashlib.md5(pin.encode(), usedforsecurity=False).digest()
50
+ return ccm.encrypt(nonce[:_NONCE_LEN], digest, None)[:16]
51
+
52
+
53
+ def is_authenticated(nonce: bytes) -> bool:
54
+ """Report whether the faucet flipped byte 15 of the nonce to 0xFF."""
55
+ return len(nonce) > 15 and nonce[15] == 0xFF
56
+
57
+
58
+ def _u16(data: bytes, offset: int) -> int:
59
+ """Two bytes, little endian."""
60
+ return int.from_bytes(data[offset : offset + 2], "little")
61
+
62
+
63
+ def _u32(data: bytes, offset: int) -> int:
64
+ """Four bytes, little endian."""
65
+ return int.from_bytes(data[offset : offset + 4], "little")
66
+
67
+
68
+ def _text(data: bytes) -> str:
69
+ """Decode an ASCII field padded with NUL bytes."""
70
+ return data.decode("ascii", "replace").replace("\x00", " ").strip()
71
+
72
+
73
+ def parse_advertisement(manufacturer_data: bytes) -> dict[str, Any]:
74
+ """Battery level and installation location, no connection required."""
75
+ if len(manufacturer_data) < 13:
76
+ return {}
77
+ return {
78
+ "battery_level": manufacturer_data[1] & 0x7F,
79
+ "location": _text(manufacturer_data[3:13]),
80
+ }
81
+
82
+
83
+ def parse_state_a(data: bytes) -> dict[str, Any]:
84
+ """Decode the status byte, valve flag, battery and detection range."""
85
+ return {
86
+ "device_status": data[0],
87
+ "sensor_error": data[1],
88
+ "operating_mode": data[2],
89
+ "valve_open": bool(data[3]),
90
+ "battery_voltage": _u16(data, 4) / 100,
91
+ "battery_level": _u16(data, 6),
92
+ "detection_range": _u16(data, 12),
93
+ }
94
+
95
+
96
+ def parse_state_b(data: bytes) -> dict[str, Any]:
97
+ """Decode the timers the faucet keeps: idle time and flush countdown."""
98
+ return {
99
+ "since_last_use": _u32(data, 4),
100
+ "until_next_flush": _u32(data, 8),
101
+ "since_reset": _u32(data, 12),
102
+ }
103
+
104
+
105
+ def parse_counter_a(data: bytes) -> dict[str, Any]:
106
+ """Decode the lifetime counters for openings, run time and flushes."""
107
+ return {
108
+ "openings": _u32(data, 0),
109
+ "open_time": _u32(data, 8) / 10, # tenths of a second
110
+ "auto_flushes": _u32(data, 12),
111
+ "manual_flushes": _u32(data, 16),
112
+ }
113
+
114
+
115
+ def parse_counter_b(data: bytes) -> dict[str, Any]:
116
+ """Decode the total volume the faucet has computed."""
117
+ # Bytes 0-3 hold the total volume; counter A repeats after that.
118
+ return {"total_volume": _u32(data, 0)}
119
+
120
+
121
+ def parse_counter_c(data: bytes) -> dict[str, Any]:
122
+ """Decode the resettable interim counter."""
123
+ return {
124
+ "interim_volume": _u32(data, 4),
125
+ "since_counter_reset": _u32(data, 8),
126
+ }
127
+
128
+
129
+ def parse_product_info(data: bytes) -> dict[str, Any]:
130
+ """Decode the sensor number and hardware revision."""
131
+ return {"sensor_number": _text(data[0:7]), "hardware_version": _text(data[10:15])}
132
+
133
+
134
+ def parse_product_name(data: bytes) -> dict[str, Any]:
135
+ """Decode the serial number and product name."""
136
+ return {"serial_number": _u32(data, 0), "product_name": _text(data[4:])}
137
+
138
+
139
+ def parse_product_location(data: bytes) -> dict[str, Any]:
140
+ """Decode the installation location and date of manufacture."""
141
+ return {"location": _text(data[0:15]), "manufactured": _text(data[15:19])}
142
+
143
+
144
+ # productParamA: one 16 bit little endian value per field, order per the app.
145
+ PARAM_A_FIELDS: tuple[str, ...] = (
146
+ "min_signal_level",
147
+ "min_signal_level_flow",
148
+ "sensitivity",
149
+ "max_ir_power",
150
+ "force_level",
151
+ "max_run_time",
152
+ "manual_flush_time",
153
+ "cleaning_mode_time",
154
+ "generic_a1",
155
+ "run_on_time",
156
+ )
157
+
158
+
159
+ def parse_param_a(data: bytes) -> dict[str, Any]:
160
+ """Decode the adjustable settings block, field by field."""
161
+ return {
162
+ name: _u16(data, index * 2)
163
+ for index, name in enumerate(PARAM_A_FIELDS)
164
+ if index * 2 + 1 < len(data)
165
+ }
166
+
167
+
168
+ def write_param_a(current: bytes, name: str, value: int) -> bytes:
169
+ """Change one field of productParamA, leaving the rest exactly as read."""
170
+ if name not in PARAM_A_FIELDS:
171
+ raise ValueError(f"unknown field: {name}")
172
+ offset = PARAM_A_FIELDS.index(name) * 2
173
+ updated = bytearray(current)
174
+ updated[offset : offset + 2] = int(value).to_bytes(2, "little")
175
+ return bytes(updated)
176
+
177
+
178
+ @dataclass(slots=True)
179
+ class FaucetData:
180
+ """Everything one poll produces, plus what the advertisement carried."""
181
+
182
+ values: dict[str, Any] = field(default_factory=dict)
183
+
184
+ def get(self, name: str, default: Any = None) -> Any:
185
+ """Return one value, or the default when the faucet did not report it."""
186
+ return self.values.get(name, default)
File without changes
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.5
2
+ Name: hansa-ble-protocol
3
+ Version: 0.2.0
4
+ Summary: Wire protocol of Hansa/Oras Bluetooth Low Energy faucets
5
+ Project-URL: Homepage, https://github.com/skjall/home-assistant-hansa-ble
6
+ Project-URL: Source, https://github.com/skjall/home-assistant-hansa-ble/tree/main/lib/hansa_ble_protocol
7
+ Project-URL: Issues, https://github.com/skjall/home-assistant-hansa-ble/issues
8
+ Author: Jan Großheim
9
+ License-Expression: MIT
10
+ Keywords: ble,bluetooth,faucet,hansa,home-assistant,oras
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Home Automation
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: cryptography>=42.0.0
19
+ Description-Content-Type: text/markdown
20
+
21
+ # hansa-ble-protocol
22
+
23
+ The wire protocol of Hansa and Oras electronic faucets that speak Bluetooth
24
+ Low Energy: how to decode what they send, how to encode what they accept, and
25
+ how the PIN exchange works.
26
+
27
+ This package handles bytes only. It opens no connections and pulls in no
28
+ Bluetooth stack, so it runs and tests without hardware. The Home Assistant
29
+ integration that uses it lives in the
30
+ [parent repository](https://github.com/skjall/home-assistant-hansa-ble); how
31
+ each byte offset was established is documented in
32
+ [docs/protocol.md](https://github.com/skjall/home-assistant-hansa-ble/blob/main/docs/protocol.md).
33
+
34
+ ```python
35
+ from hansa_ble_protocol import parse_state_a, auth_response
36
+
37
+ state = parse_state_a(raw_bytes)
38
+ print(state["valve_open"], state["battery_level"])
39
+
40
+ # Answer the faucet's challenge with the PIN from the vendor app.
41
+ response = auth_response(nonce, password, "1234")
42
+ ```
43
+
44
+ Not affiliated with, endorsed by, or supported by Hansa or Oras.
45
+
46
+ ## License
47
+
48
+ MIT
@@ -0,0 +1,7 @@
1
+ hansa_ble_protocol/__init__.py,sha256=XjqxuEvACrTPiletp7wJ5JDCvvzSgfBEjy-6aab_Nqo,2019
2
+ hansa_ble_protocol/const.py,sha256=fZmrGbsdHYxc8rIxNezS7ZsL7xYlT2cYw1kk06y7JfU,1676
3
+ hansa_ble_protocol/protocol.py,sha256=PvJqFpPzZ813LQHZL4YoMCizV4jnJa2rjSFMDDR_wNM,6398
4
+ hansa_ble_protocol/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ hansa_ble_protocol-0.2.0.dist-info/METADATA,sha256=479yh_fMV5ia8p7dqJrfyg9opCJCwW_fwWpLkevFQtE,1818
6
+ hansa_ble_protocol-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
7
+ hansa_ble_protocol-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any