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,,
|