habitron-client 0.1.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.
- habitron_client-0.1.0/LICENSE +17 -0
- habitron_client-0.1.0/PKG-INFO +28 -0
- habitron_client-0.1.0/README.md +17 -0
- habitron_client-0.1.0/pyproject.toml +19 -0
- habitron_client-0.1.0/src/habitron_client/__init__.py +5 -0
- habitron_client-0.1.0/src/habitron_client/client.py +607 -0
- habitron_client-0.1.0/src/habitron_client/const.py +65 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
Copyright 2026 Habitron GmbH
|
|
6
|
+
|
|
7
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
8
|
+
you may not use this file except in compliance with the License.
|
|
9
|
+
You may obtain a copy of the License at
|
|
10
|
+
|
|
11
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
12
|
+
|
|
13
|
+
Unless required by applicable law or agreed to in writing, software
|
|
14
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
15
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
16
|
+
See the License for the specific language governing permissions and
|
|
17
|
+
limitations under the License.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: habitron-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Asynchronous API client for Habitron SmartHub
|
|
5
|
+
Author: Habitron GmbH
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: pyyaml>=6.0
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# Habitron Client
|
|
13
|
+
|
|
14
|
+
An asynchronous API client for the Habitron SmartHub, designed for integration with Home Assistant.
|
|
15
|
+
|
|
16
|
+
## Features
|
|
17
|
+
|
|
18
|
+
- Direct socket communication with Habitron SmartHub.
|
|
19
|
+
- Automatic CRC16 calculation and command wrapping.
|
|
20
|
+
- High-level methods for controlling outputs, dimmers, RGB, and shutters.
|
|
21
|
+
- Parsing of system status and module definitions via YAML.
|
|
22
|
+
- Discovery of SmartHubs on the local network via UDP.
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install habitron-client
|
|
28
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Habitron Client
|
|
2
|
+
|
|
3
|
+
An asynchronous API client for the Habitron SmartHub, designed for integration with Home Assistant.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Direct socket communication with Habitron SmartHub.
|
|
8
|
+
- Automatic CRC16 calculation and command wrapping.
|
|
9
|
+
- High-level methods for controlling outputs, dimmers, RGB, and shutters.
|
|
10
|
+
- Parsing of system status and module definitions via YAML.
|
|
11
|
+
- Discovery of SmartHubs on the local network via UDP.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install habitron-client
|
|
17
|
+
```
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "habitron-client"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Asynchronous API client for Habitron SmartHub"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [{ name = "Habitron GmbH" }]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"pyyaml>=6.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[tool.ruff]
|
|
18
|
+
# Deine Ruff-Konfiguration bleibt hier erhalten
|
|
19
|
+
line-length = 88
|
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
"""Habitron API client for socket communication and string handling."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import socket
|
|
5
|
+
import struct
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from .const import SMHUB_COMMANDS
|
|
11
|
+
|
|
12
|
+
_LOGGER = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HabitronError(Exception):
|
|
17
|
+
"""Base exception for the Habitron client."""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
class TimeoutException(HabitronError):
|
|
21
|
+
"""Error to indicate a network or command timeout."""
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def init_crc16_tbl() -> list[int]:
|
|
26
|
+
"""Prepare the crc16 table."""
|
|
27
|
+
res: list[int] = []
|
|
28
|
+
for byte in range(256):
|
|
29
|
+
crc = 0x0000
|
|
30
|
+
for _ in range(8):
|
|
31
|
+
if (byte ^ crc) & 0x0001:
|
|
32
|
+
crc = (crc >> 1) ^ 0xA001
|
|
33
|
+
else:
|
|
34
|
+
crc >>= 1
|
|
35
|
+
byte >>= 1
|
|
36
|
+
res.append(crc)
|
|
37
|
+
return res
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Pre-calculate CRC table
|
|
41
|
+
__crc16_tbl: list[int] = init_crc16_tbl()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def calc_crc(data: bytes) -> int:
|
|
45
|
+
"""Calculate crc16 for byte string."""
|
|
46
|
+
crc = 0xFFFF
|
|
47
|
+
for byt in data:
|
|
48
|
+
idx = __crc16_tbl[(crc ^ int(byt)) & 0xFF]
|
|
49
|
+
crc = ((crc >> 8) & 0xFF) ^ idx
|
|
50
|
+
return ((crc << 8) & 0xFF00) | ((crc >> 8) & 0x00FF)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def check_crc(msg: bytes) -> bool:
|
|
54
|
+
"""Check crc of message."""
|
|
55
|
+
msg_crc = int.from_bytes(msg[-3:-1], "little")
|
|
56
|
+
return calc_crc(msg[:-3]) == msg_crc
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def wrap_command(cmd_string: str) -> str:
|
|
60
|
+
"""Take command and add prefix, crc, postfix."""
|
|
61
|
+
cmd_prefix = "¨\0\0\x0bSmartConfig\x05michlS\x05"
|
|
62
|
+
cmd_postfix = "\x3f"
|
|
63
|
+
full_string = cmd_prefix + cmd_string
|
|
64
|
+
cmd_len = len(full_string) + 3
|
|
65
|
+
full_string = full_string[0] + chr(cmd_len) + full_string[2 : cmd_len - 3]
|
|
66
|
+
cmd_crc = calc_crc(full_string.encode("iso8859-1"))
|
|
67
|
+
crc_low = cmd_crc & 0xFF
|
|
68
|
+
crc_high = (cmd_crc - crc_low) >> 8
|
|
69
|
+
cmd_postfix = chr(crc_high) + chr(crc_low) + cmd_postfix
|
|
70
|
+
return full_string + cmd_postfix
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def format_block_output(byte_str: bytes) -> str:
|
|
74
|
+
"""Format block hex output with lines."""
|
|
75
|
+
lbs = len(byte_str)
|
|
76
|
+
res_str = ""
|
|
77
|
+
ptr = 0
|
|
78
|
+
while ptr < lbs:
|
|
79
|
+
line = ""
|
|
80
|
+
end_l = min([ptr + 10, lbs])
|
|
81
|
+
for i in range(end_l - ptr):
|
|
82
|
+
line = line + f"{f'{byte_str[ptr + i]:02X}'} "
|
|
83
|
+
res_str += f"{f'{ptr:03d}'} {line}{chr(13)}"
|
|
84
|
+
ptr += 10
|
|
85
|
+
return res_str
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_own_ip() -> str:
|
|
89
|
+
"""Return string of own ip."""
|
|
90
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
91
|
+
s.connect(("8.8.8.8", 80))
|
|
92
|
+
own_ip = s.getsockname()[0]
|
|
93
|
+
s.close()
|
|
94
|
+
return own_ip
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_host_ip(host_name: str) -> str:
|
|
98
|
+
"""Get IP from DNS host name."""
|
|
99
|
+
return socket.gethostbyname(host_name)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class HabitronClient:
|
|
103
|
+
"""Habitron client for direct socket communication and protocol parsing."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, host: str, port: int = 7777) -> None:
|
|
106
|
+
"""Init client."""
|
|
107
|
+
self.host = host
|
|
108
|
+
self.port = port
|
|
109
|
+
self.logger = logging.getLogger(__name__)
|
|
110
|
+
|
|
111
|
+
def _send_receive(self, sck: socket.socket, cmd_str: str) -> bytes:
|
|
112
|
+
"""Send string to SmartHub and wait for response with timeout."""
|
|
113
|
+
try:
|
|
114
|
+
sck.send(cmd_str.encode("iso8859-1"))
|
|
115
|
+
resp_bytes = sck.recv(30)
|
|
116
|
+
if len(resp_bytes) < 30:
|
|
117
|
+
return b"OK"
|
|
118
|
+
resp_len = resp_bytes[29] * 256 + resp_bytes[28]
|
|
119
|
+
resp_bytes = b""
|
|
120
|
+
# Read remaining bytes
|
|
121
|
+
while len(resp_bytes) < resp_len + 3:
|
|
122
|
+
buffer = sck.recv(resp_len + 3)
|
|
123
|
+
if not buffer:
|
|
124
|
+
raise TimeoutException("Connection dropped by SmartHub")
|
|
125
|
+
resp_bytes = resp_bytes + buffer
|
|
126
|
+
resp_bytes = resp_bytes[0:resp_len]
|
|
127
|
+
except TimeoutError as exc:
|
|
128
|
+
raise TimeoutException("Timeout during send_receive") from exc
|
|
129
|
+
return resp_bytes
|
|
130
|
+
|
|
131
|
+
def _send_receive_crc(self, sck: socket.socket, cmd_str: str) -> tuple[bytes, int]:
|
|
132
|
+
"""Send string to SmartHub and wait for response returning crc."""
|
|
133
|
+
try:
|
|
134
|
+
sck.send(cmd_str.encode("iso8859-1"))
|
|
135
|
+
resp_bytes = sck.recv(30)
|
|
136
|
+
if len(resp_bytes) < 30:
|
|
137
|
+
return b"OK", 0
|
|
138
|
+
resp_len = resp_bytes[29] * 256 + resp_bytes[28]
|
|
139
|
+
resp_bytes = b""
|
|
140
|
+
# Read remaining bytes
|
|
141
|
+
while len(resp_bytes) < resp_len + 3:
|
|
142
|
+
buffer = sck.recv(resp_len + 3)
|
|
143
|
+
if not buffer:
|
|
144
|
+
raise TimeoutException("Connection dropped by SmartHub")
|
|
145
|
+
resp_bytes = resp_bytes + buffer
|
|
146
|
+
crc = resp_bytes[-2] * 256 + resp_bytes[-3]
|
|
147
|
+
resp_bytes = resp_bytes[0:resp_len]
|
|
148
|
+
except TimeoutError as exc:
|
|
149
|
+
raise TimeoutException("Timeout during send_receive_crc") from exc
|
|
150
|
+
return resp_bytes, crc
|
|
151
|
+
|
|
152
|
+
def send_command_sync(self, cmd_string: str, time_out_sec: float = 10.0) -> bytes:
|
|
153
|
+
"""Synchronous version of send command."""
|
|
154
|
+
sck = socket.socket()
|
|
155
|
+
try:
|
|
156
|
+
sck.settimeout(time_out_sec)
|
|
157
|
+
sck.connect((self.host, self.port))
|
|
158
|
+
full_string = wrap_command(cmd_string)
|
|
159
|
+
resp_bytes = self._send_receive(sck, full_string)
|
|
160
|
+
finally:
|
|
161
|
+
sck.close()
|
|
162
|
+
return resp_bytes
|
|
163
|
+
|
|
164
|
+
def send_command_crc_sync(
|
|
165
|
+
self, cmd_string: str, time_out_sec: float = 10.0
|
|
166
|
+
) -> tuple[bytes, int]:
|
|
167
|
+
"""Synchronous version of send command crc."""
|
|
168
|
+
sck = socket.socket()
|
|
169
|
+
try:
|
|
170
|
+
sck.settimeout(time_out_sec)
|
|
171
|
+
sck.connect((self.host, self.port))
|
|
172
|
+
full_string = wrap_command(cmd_string)
|
|
173
|
+
resp_bytes, crc = self._send_receive_crc(sck, full_string)
|
|
174
|
+
finally:
|
|
175
|
+
sck.close()
|
|
176
|
+
return resp_bytes, crc
|
|
177
|
+
|
|
178
|
+
def send_only(self, cmd_string: str) -> None:
|
|
179
|
+
"""Send string and return without waiting for response."""
|
|
180
|
+
try:
|
|
181
|
+
self.send_command_sync(cmd_string)
|
|
182
|
+
except Exception as e: # noqa: BLE001
|
|
183
|
+
self.logger.warning("Error in send_only: %s", e)
|
|
184
|
+
|
|
185
|
+
def _execute(
|
|
186
|
+
self,
|
|
187
|
+
cmd_key: str,
|
|
188
|
+
replacements: dict[str, Any] | None = None,
|
|
189
|
+
timeout: float = 10.0,
|
|
190
|
+
crc: bool = False,
|
|
191
|
+
send_only: bool = False,
|
|
192
|
+
) -> Any:
|
|
193
|
+
"""Helper to format and execute commands dynamically."""
|
|
194
|
+
cmd_str = SMHUB_COMMANDS[cmd_key]
|
|
195
|
+
if replacements:
|
|
196
|
+
for key, val in replacements.items():
|
|
197
|
+
# Automatically convert integers to characters for the protocol
|
|
198
|
+
str_val = chr(val) if isinstance(val, int) else val
|
|
199
|
+
cmd_str = cmd_str.replace(key, str_val)
|
|
200
|
+
|
|
201
|
+
if send_only:
|
|
202
|
+
self.send_only(cmd_str)
|
|
203
|
+
return None
|
|
204
|
+
if crc:
|
|
205
|
+
return self.send_command_crc_sync(cmd_str, time_out_sec=timeout)
|
|
206
|
+
return self.send_command_sync(cmd_str, time_out_sec=timeout)
|
|
207
|
+
|
|
208
|
+
# --- High-level API Methods ---
|
|
209
|
+
|
|
210
|
+
def get_smhub_info(self) -> dict:
|
|
211
|
+
"""Get basic infos of SmartHub parsed from YAML."""
|
|
212
|
+
resp_bytes = self._execute("GET_SMHUB_INFO", timeout=10.0)
|
|
213
|
+
decoded_resp = resp_bytes.decode("iso8859-1")
|
|
214
|
+
info = yaml.load(decoded_resp, Loader=yaml.Loader)
|
|
215
|
+
if not isinstance(info, dict):
|
|
216
|
+
self.logger.warning("Invalid SmartHub info received")
|
|
217
|
+
raise TimeoutException("Invalid response format")
|
|
218
|
+
return info
|
|
219
|
+
|
|
220
|
+
def get_smhub_update(self, hbtn_version: str) -> dict:
|
|
221
|
+
"""Get current sensor and status values parsed from YAML."""
|
|
222
|
+
vlen = len(hbtn_version)
|
|
223
|
+
args_len = vlen + 1
|
|
224
|
+
resp_bytes = self._execute(
|
|
225
|
+
"GET_SMHUB_UPDATE",
|
|
226
|
+
{
|
|
227
|
+
"<len>": chr(args_len & 0xFF) + chr(args_len >> 8),
|
|
228
|
+
"<vlen>": vlen,
|
|
229
|
+
"<vers>": hbtn_version,
|
|
230
|
+
},
|
|
231
|
+
timeout=8.0,
|
|
232
|
+
)
|
|
233
|
+
return yaml.load(resp_bytes.decode("iso8859-1"), Loader=yaml.Loader)
|
|
234
|
+
|
|
235
|
+
def send_network_info(
|
|
236
|
+
self, ipv4: str, tok: str, mac: str, is_addon: bool, version: str
|
|
237
|
+
) -> None:
|
|
238
|
+
"""Send home assistant ipv4, token and version."""
|
|
239
|
+
if not tok:
|
|
240
|
+
return
|
|
241
|
+
ip_len = len(ipv4)
|
|
242
|
+
tk_len = len(tok)
|
|
243
|
+
vlen = len(version)
|
|
244
|
+
if not is_addon:
|
|
245
|
+
nmbrs = mac.split(":")
|
|
246
|
+
for i in range(len(nmbrs)):
|
|
247
|
+
idx = int("0x" + nmbrs[len(nmbrs) - i - 1], 0) & 0x7F
|
|
248
|
+
if idx < tk_len:
|
|
249
|
+
tok = tok[:idx] + tok[idx + 1 :] + tok[idx]
|
|
250
|
+
|
|
251
|
+
# Calculate correct total arguments length
|
|
252
|
+
args_len = ip_len + tk_len + vlen + 3
|
|
253
|
+
|
|
254
|
+
self._execute(
|
|
255
|
+
"SEND_NETWORK_INFO",
|
|
256
|
+
{
|
|
257
|
+
"<len>": chr(args_len & 0xFF) + chr(args_len >> 8),
|
|
258
|
+
"<iplen>": ip_len,
|
|
259
|
+
"<ipv4>": ipv4,
|
|
260
|
+
"<toklen>": tk_len,
|
|
261
|
+
"<tok>": tok,
|
|
262
|
+
"<vlen>": vlen,
|
|
263
|
+
"<vers>": version,
|
|
264
|
+
},
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def reinit_hub(self, mode: int) -> bytes:
|
|
268
|
+
"""Restart event server on hub."""
|
|
269
|
+
return self._execute("REINIT_HUB", {"<opr>": mode}, timeout=12.0)
|
|
270
|
+
|
|
271
|
+
def get_smr(self) -> bytes:
|
|
272
|
+
"""Get router SMR information."""
|
|
273
|
+
resp = self._execute("GET_ROUTER_SMR", timeout=15.0)
|
|
274
|
+
return b"" if resp.decode("iso8859-1").startswith("Error") else resp
|
|
275
|
+
|
|
276
|
+
def set_output(self, mod_addr: int, nmbr: int, val: bool) -> None:
|
|
277
|
+
"""Send turn_on/turn_off command."""
|
|
278
|
+
cmd = "SET_OUTPUT_ON" if val else "SET_OUTPUT_OFF"
|
|
279
|
+
self._execute(cmd, {"<mod>": mod_addr, "<arg1>": nmbr}, send_only=True)
|
|
280
|
+
|
|
281
|
+
def set_dimmval(self, mod_addr: int, nmbr: int, val: int) -> None:
|
|
282
|
+
"""Send value to dimm output."""
|
|
283
|
+
self._execute(
|
|
284
|
+
"SET_DIMMER_VALUE", {"<mod>": mod_addr, "<arg1>": nmbr, "<arg2>": val}
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def set_rgb_output(self, mod_addr: int, nmbr: int, val: bool) -> None:
|
|
288
|
+
"""Turn RGB light on/off."""
|
|
289
|
+
cmd = "SET_RGB_ON" if val else "SET_RGB_OFF"
|
|
290
|
+
self._execute(cmd, {"<mod>": mod_addr, "<lno>": nmbr})
|
|
291
|
+
|
|
292
|
+
def set_rgbval(self, mod_addr: int, nmbr: int, val: list) -> None:
|
|
293
|
+
"""Send value to rgb output."""
|
|
294
|
+
self._execute(
|
|
295
|
+
"SET_RGB_COL",
|
|
296
|
+
{
|
|
297
|
+
"<mod>": mod_addr,
|
|
298
|
+
"<lno>": nmbr,
|
|
299
|
+
"<rd>": val[0],
|
|
300
|
+
"<gn>": val[1],
|
|
301
|
+
"<bl>": val[2],
|
|
302
|
+
},
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
def set_shutterpos(self, mod_addr: int, nmbr: int, val: int) -> None:
|
|
306
|
+
"""Send value to shutter position."""
|
|
307
|
+
self._execute(
|
|
308
|
+
"SET_SHUTTER_POSITION", {"<mod>": mod_addr, "<arg1>": nmbr, "<arg2>": val}
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
def set_blindtilt(self, mod_addr: int, nmbr: int, val: int) -> None:
|
|
312
|
+
"""Send value to blind tilt."""
|
|
313
|
+
self._execute(
|
|
314
|
+
"SET_BLIND_TILT", {"<mod>": mod_addr, "<arg1>": nmbr, "<arg2>": val}
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
def set_flag(self, mod_addr: int, nmbr: int, val: bool) -> None:
|
|
318
|
+
"""Send flag on/flag off command."""
|
|
319
|
+
cmd = "SET_FLAG_ON" if val else "SET_FLAG_OFF"
|
|
320
|
+
self._execute(cmd, {"<mod>": mod_addr, "<fno>": nmbr})
|
|
321
|
+
|
|
322
|
+
def inc_dec_counter(self, mod_addr: int, nmbr: int, val: int) -> None:
|
|
323
|
+
"""Send counter up/down command."""
|
|
324
|
+
cmd = "COUNTR_UP" if val == 1 else "COUNTR_DOWN"
|
|
325
|
+
self._execute(cmd, {"<mod>": mod_addr, "<cno>": nmbr})
|
|
326
|
+
|
|
327
|
+
def set_setpoint(self, mod_addr: int, nmbr: int, val: int) -> None:
|
|
328
|
+
"""Send two byte value for setpoint definition."""
|
|
329
|
+
hi_val = int(val / 256)
|
|
330
|
+
lo_val = val - 256 * hi_val
|
|
331
|
+
self._execute(
|
|
332
|
+
"SET_SETPOINT_VALUE",
|
|
333
|
+
{"<mod>": mod_addr, "<arg1>": nmbr, "<arg3>": hi_val, "<arg2>": lo_val},
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
def set_climate_mode(self, mod_addr: int, cmode: int, ctl12: int) -> None:
|
|
337
|
+
"""Set climate mode for given module."""
|
|
338
|
+
self._execute(
|
|
339
|
+
"SET_CLIM_MODE", {"<mod>": mod_addr, "<cmode>": cmode, "<ctl12>": ctl12}
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
def call_dir_command(self, mod_addr: int, nmbr: int) -> None:
|
|
343
|
+
"""Call of direct command of nmbr."""
|
|
344
|
+
self._execute("CALL_DIR_COMMAND", {"<mod>": mod_addr, "<cno>": nmbr})
|
|
345
|
+
|
|
346
|
+
def call_vis_command(self, mod_addr: int, nmbr: int) -> None:
|
|
347
|
+
"""Call of visualization command of nmbr."""
|
|
348
|
+
hi_no = int(nmbr / 256)
|
|
349
|
+
lo_no = nmbr - 256 * hi_no
|
|
350
|
+
self._execute(
|
|
351
|
+
"CALL_VIS_COMMAND", {"<mod>": mod_addr, "<vish>": hi_no, "<visl>": lo_no}
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
def call_coll_command(self, nmbr: int) -> None:
|
|
355
|
+
"""Call collective command of nmbr."""
|
|
356
|
+
self._execute("CALL_COLL_COMMAND", {"<cno>": nmbr})
|
|
357
|
+
|
|
358
|
+
def set_group_mode(self, grp_no: int, mode: int) -> None:
|
|
359
|
+
"""Set mode for given group."""
|
|
360
|
+
self._execute("SET_GROUP_MODE", {"<mod>": grp_no, "<arg1>": mode})
|
|
361
|
+
|
|
362
|
+
def set_log_level(self, hdlr: int, level: int) -> None:
|
|
363
|
+
"""Set new logging level."""
|
|
364
|
+
self._execute("SET_LOG_LEVEL", {"<hdlr>": hdlr, "<lvl>": level})
|
|
365
|
+
|
|
366
|
+
def send_message(self, mod_addr: int, msg_id: Any) -> None:
|
|
367
|
+
"""Send message to module."""
|
|
368
|
+
self._execute("SEND_MESSAGE", {"<mod>": mod_addr, "<tim>": 15, "<msg>": msg_id})
|
|
369
|
+
|
|
370
|
+
def send_sms(self, mod_addr: int, msg_id: Any, ct_id: int) -> None:
|
|
371
|
+
"""Send sms message to module."""
|
|
372
|
+
self._execute("SEND_SMS", {"<mod>": mod_addr, "<sms>": ct_id, "<msg>": msg_id})
|
|
373
|
+
|
|
374
|
+
def hub_restart(self) -> None:
|
|
375
|
+
"""Restart hub."""
|
|
376
|
+
self._execute("RESTART_HUB")
|
|
377
|
+
|
|
378
|
+
def hub_reboot(self) -> None:
|
|
379
|
+
"""Reboot hub."""
|
|
380
|
+
self._execute("REBOOT_HUB")
|
|
381
|
+
|
|
382
|
+
def module_restart(self, mod_nmbr: int) -> None:
|
|
383
|
+
"""Restart a single module or all with arg 0xFF or router if arg 0."""
|
|
384
|
+
cmd = "REBOOT_MODULE" if mod_nmbr > 0 else "REBOOT_ROUTER"
|
|
385
|
+
self._execute(cmd, {"<mod>": mod_nmbr} if mod_nmbr > 0 else None)
|
|
386
|
+
|
|
387
|
+
def restart_fwd_tbl(self) -> None:
|
|
388
|
+
"""Restart forwarding table of router."""
|
|
389
|
+
self._execute("RESTART_FORWARD_TABLE")
|
|
390
|
+
|
|
391
|
+
def start_mirror(self) -> bytes:
|
|
392
|
+
"""Start mirror on specified router."""
|
|
393
|
+
return self._execute("START_MIRROR")
|
|
394
|
+
|
|
395
|
+
def stop_mirror(self) -> None:
|
|
396
|
+
"""Stop mirror on specified router."""
|
|
397
|
+
self._execute("STOP_MIRROR")
|
|
398
|
+
|
|
399
|
+
def get_smhub_version(self) -> bytes:
|
|
400
|
+
"""Query of SmartHub firmware."""
|
|
401
|
+
return self._execute("GET_SMHUB_FIRMWARE")
|
|
402
|
+
|
|
403
|
+
def get_router_status(self) -> bytes:
|
|
404
|
+
"""Get router status."""
|
|
405
|
+
resp = self._execute("GET_ROUTER_STATUS")
|
|
406
|
+
return b"" if resp.decode("iso8859-1").startswith("Error") else resp
|
|
407
|
+
|
|
408
|
+
def get_router_modules(self) -> bytes:
|
|
409
|
+
"""Get summary of all Habitron modules of a router."""
|
|
410
|
+
resp = self._execute("GET_MODULES")
|
|
411
|
+
return b"" if resp.decode("iso8859-1").startswith("Error") else resp
|
|
412
|
+
|
|
413
|
+
def get_global_descriptions(self) -> bytes:
|
|
414
|
+
"""Get descriptions of commands, etc."""
|
|
415
|
+
return self._execute("GET_GLOBAL_DESCRIPTIONS")
|
|
416
|
+
|
|
417
|
+
def get_error_status(self) -> bytes:
|
|
418
|
+
"""Get error byte for each module."""
|
|
419
|
+
return self._execute("GET_CURRENT_ERROR")
|
|
420
|
+
|
|
421
|
+
def get_module_definitions(self, mod_addr: int) -> bytes:
|
|
422
|
+
"""Get summary of Habitron module: names, commands, etc."""
|
|
423
|
+
resp = self._execute("GET_MODULE_SMC", {"<mod>": mod_addr})
|
|
424
|
+
return b"" if resp.decode("iso8859-1").startswith("Error") else resp
|
|
425
|
+
|
|
426
|
+
def get_module_settings(self, mod_addr: int) -> bytes:
|
|
427
|
+
"""Get settings of Habitron module."""
|
|
428
|
+
resp = self._execute("GET_MODULE_SMG", {"<mod>": mod_addr})
|
|
429
|
+
return b"" if resp.decode("iso8859-1").startswith("Error") else resp
|
|
430
|
+
|
|
431
|
+
def get_compact_status(self) -> tuple[bytes, int]:
|
|
432
|
+
"""Get compact status for all modules."""
|
|
433
|
+
return self._execute("GET_COMPACT_STATUS", timeout=15.0, crc=True)
|
|
434
|
+
|
|
435
|
+
def get_module_status(self, mod_nmbr: int) -> tuple[bytes, int]:
|
|
436
|
+
"""Get compact status for all modules."""
|
|
437
|
+
return self._execute(
|
|
438
|
+
"GET_MODULE_STATUS", {"<mod>": mod_nmbr}, timeout=15.0, crc=True
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
def handle_firmware(self, mod_nmbr: int) -> tuple[bytes, int]:
|
|
442
|
+
"""Handle router/module firmware update file status."""
|
|
443
|
+
cmd = "GET_MODULE_FW_FILEVS" if mod_nmbr else "GET_ROUTER_FW_FILEVS"
|
|
444
|
+
return self._execute(cmd, {"<mod>": mod_nmbr}, timeout=5.0, crc=True)
|
|
445
|
+
|
|
446
|
+
def update_firmware(self, mod_nmbr: int) -> tuple[bytes, int]:
|
|
447
|
+
"""Start router/module firmware updates."""
|
|
448
|
+
return self._execute(
|
|
449
|
+
"DO_FW_UPDATE", {"<mod>": mod_nmbr}, timeout=1000.0, crc=True
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
def power_cycle_channel_down(self, channel: int) -> None:
|
|
453
|
+
"""Power down a router channel."""
|
|
454
|
+
self._execute(
|
|
455
|
+
"POWER_DWN_CHAN", {"<msk>": 1 << (channel - 1)}, timeout=1000.0, crc=True
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
def power_cycle_channel_up(self, channel: int) -> None:
|
|
459
|
+
"""Set power on again."""
|
|
460
|
+
self._execute(
|
|
461
|
+
"POWER_UP_CHAN", {"<msk>": 1 << (channel - 1)}, timeout=1000.0, crc=True
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
def send_devregid(self, mod_nmbr: int, devreg_id: str) -> None:
|
|
465
|
+
"""Send device registry id to module."""
|
|
466
|
+
self._execute(
|
|
467
|
+
"SEND_MD_ID",
|
|
468
|
+
{"<mod>": mod_nmbr, "<len>": chr(len(devreg_id)), "<id>": devreg_id},
|
|
469
|
+
send_only=True,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def discover_smarthubs() -> list[dict[str, str]]:
|
|
474
|
+
"""Discover SmartHub and SmartServer hardware on the network."""
|
|
475
|
+
smhub_port = 30718
|
|
476
|
+
own_ip = get_own_ip()
|
|
477
|
+
timeout = 2
|
|
478
|
+
|
|
479
|
+
req_header_data = [0x00, 0x00, 0x00, 0xF6]
|
|
480
|
+
req_header = struct.pack("B" * len(req_header_data), *req_header_data)
|
|
481
|
+
resp_header_data = [0x00, 0x00, 0x00, 0xF7]
|
|
482
|
+
resp_header = struct.pack("B" * len(resp_header_data), *resp_header_data)
|
|
483
|
+
|
|
484
|
+
network_socket = socket.socket(
|
|
485
|
+
socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP
|
|
486
|
+
)
|
|
487
|
+
network_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, True)
|
|
488
|
+
network_socket.settimeout(timeout)
|
|
489
|
+
network_socket.bind((own_ip, 0))
|
|
490
|
+
|
|
491
|
+
network_socket.sendto(req_header, ("<broadcast>", smhub_port))
|
|
492
|
+
|
|
493
|
+
smarthubs = []
|
|
494
|
+
|
|
495
|
+
try:
|
|
496
|
+
while True:
|
|
497
|
+
response, address_info = network_socket.recvfrom(1024)
|
|
498
|
+
|
|
499
|
+
smhub_ip = address_info[0]
|
|
500
|
+
_LOGGER.info("SmartHub found at address %s", smhub_ip)
|
|
501
|
+
|
|
502
|
+
if response[0:4] == resp_header and smhub_ip != "0.0.0.0":
|
|
503
|
+
smhub_version = f"{response[7]}.{response[6]}.{response[5]}"
|
|
504
|
+
smhub_mac = f"{response[24]:02X}:{response[25]:02X}:{response[26]:02X}:{response[27]:02X}:{response[28]:02X}:{response[29]:02X}"
|
|
505
|
+
smhub_serial = (
|
|
506
|
+
f"{response[20]:c}{response[21]:c}{response[22]:c}{response[23]:c}"
|
|
507
|
+
)
|
|
508
|
+
smhub_type = f"{response[8]:c}-{response[9]:c}"
|
|
509
|
+
smarthub_info = {
|
|
510
|
+
"type": smhub_type,
|
|
511
|
+
"version": smhub_version,
|
|
512
|
+
"serial": smhub_serial,
|
|
513
|
+
"mac": smhub_mac,
|
|
514
|
+
"ip": smhub_ip,
|
|
515
|
+
}
|
|
516
|
+
smarthubs.append(smarthub_info)
|
|
517
|
+
except TimeoutError:
|
|
518
|
+
pass
|
|
519
|
+
finally:
|
|
520
|
+
network_socket.close()
|
|
521
|
+
|
|
522
|
+
return smarthubs
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def query_smarthub(smhub_ip: str) -> dict[str, str]:
|
|
526
|
+
"""Read properties of identified SmartIP or SmartHub."""
|
|
527
|
+
smartip_info: dict[str, str] = {}
|
|
528
|
+
smhub_port = 30718
|
|
529
|
+
timeout = 1
|
|
530
|
+
|
|
531
|
+
req_header_data = [0x00, 0x00, 0x00, 0xF6]
|
|
532
|
+
req_header = struct.pack("B" * len(req_header_data), *req_header_data)
|
|
533
|
+
resp_header_data = [0x00, 0x00, 0x00, 0xF7]
|
|
534
|
+
resp_header = struct.pack("B" * len(resp_header_data), *resp_header_data)
|
|
535
|
+
|
|
536
|
+
network_socket = socket.socket(
|
|
537
|
+
socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP
|
|
538
|
+
)
|
|
539
|
+
network_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, True)
|
|
540
|
+
network_socket.settimeout(timeout)
|
|
541
|
+
|
|
542
|
+
try:
|
|
543
|
+
network_socket.sendto(req_header, (smhub_ip, smhub_port))
|
|
544
|
+
response, address_info = network_socket.recvfrom(1024)
|
|
545
|
+
|
|
546
|
+
smhub_ip = address_info[0]
|
|
547
|
+
|
|
548
|
+
if response[0:4] == resp_header and smhub_ip != "0.0.0.0":
|
|
549
|
+
smhub_version = f"{response[7]}.{response[6]}.{response[5]}"
|
|
550
|
+
smhub_mac = f"{response[24]:02X}:{response[25]:02X}:{response[26]:02X}:{response[27]:02X}:{response[28]:02X}:{response[29]:02X}"
|
|
551
|
+
smhub_serial = (
|
|
552
|
+
f"{response[20]:c}{response[21]:c}{response[22]:c}{response[23]:c}"
|
|
553
|
+
)
|
|
554
|
+
smhub_type = f"{response[8]:c}-{response[9]:c}"
|
|
555
|
+
|
|
556
|
+
if smhub_type == "E-5":
|
|
557
|
+
smhub_name = f"SmartIP_{smhub_mac.replace(':', '')}"
|
|
558
|
+
else:
|
|
559
|
+
smhub_name = f"SmartHub_{smhub_mac.replace(':', '')}"
|
|
560
|
+
|
|
561
|
+
smartip_info = {
|
|
562
|
+
"name": smhub_name,
|
|
563
|
+
"hostname": "",
|
|
564
|
+
"type": smhub_type,
|
|
565
|
+
"version": smhub_version,
|
|
566
|
+
"serial": smhub_serial,
|
|
567
|
+
"mac": smhub_mac,
|
|
568
|
+
"ip": smhub_ip,
|
|
569
|
+
}
|
|
570
|
+
except TimeoutError:
|
|
571
|
+
network_socket.close()
|
|
572
|
+
return {}
|
|
573
|
+
|
|
574
|
+
network_socket.close()
|
|
575
|
+
try:
|
|
576
|
+
smartip_info["hostname"] = socket.gethostbyaddr(smhub_ip)[0].split(".")[0]
|
|
577
|
+
except (socket.herror, socket.gaierror, OSError, TimeoutException):
|
|
578
|
+
smartip_info["hostname"] = ""
|
|
579
|
+
return smartip_info
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def test_connection(host_name: str) -> tuple[bool, str]:
|
|
583
|
+
"""Test connectivity to SmartHub is OK."""
|
|
584
|
+
try:
|
|
585
|
+
host = get_host_ip(host_name)
|
|
586
|
+
except socket.gaierror as exc:
|
|
587
|
+
raise socket.gaierror from exc
|
|
588
|
+
|
|
589
|
+
client = HabitronClient(host, port=7777)
|
|
590
|
+
|
|
591
|
+
try:
|
|
592
|
+
resp_bytes = client._execute("CHECK_COMM_STATUS", timeout=15.0) # noqa: SLF001
|
|
593
|
+
resp_string = resp_bytes.decode("iso8859-1")
|
|
594
|
+
conn_ok = resp_string.startswith("OK")
|
|
595
|
+
except (TimeoutException, ConnectionRefusedError):
|
|
596
|
+
return False, ""
|
|
597
|
+
|
|
598
|
+
if conn_ok:
|
|
599
|
+
smhub_info = query_smarthub(host)
|
|
600
|
+
host_name = smhub_info.get("name", "")
|
|
601
|
+
else:
|
|
602
|
+
host_name = ""
|
|
603
|
+
|
|
604
|
+
return conn_ok, host_name
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
# End of client definition.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Constants for habitron client."""
|
|
2
|
+
|
|
3
|
+
from typing import Final
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
SMHUB_COMMANDS: Final[dict[str, str]] = {
|
|
7
|
+
"GET_MODULES": "\x0a\1\2\x01\0\0\0",
|
|
8
|
+
"GET_MODULE_SMG": "\x0a\2\7\x01<mod>\0\0",
|
|
9
|
+
"GET_MODULE_SMC": "\x0a\3\7\x01<mod>\0\0",
|
|
10
|
+
"GET_ROUTER_SMR": "\x0a\4\3\x01\0\0\0",
|
|
11
|
+
"GET_ROUTER_STATUS": "\x0a\4\4\x01\0\0\0",
|
|
12
|
+
"GET_ROUTER_FW_FILEVS": "\x0a\4\x0a\x01\0\0\0",
|
|
13
|
+
"GET_MODULE_FW_FILEVS": "\x0a\5\x0a\x01<mod>\0\0",
|
|
14
|
+
"GET_MODULE_STATUS": "\x0a\5\1\x01<mod>\0\0",
|
|
15
|
+
"GET_COMPACT_STATUS": "\x0a\5\2\x01\xff\0\0", # compact status of all modules (0xFF)
|
|
16
|
+
"GET_SMHUB_BOOT_STATUS": "\x0a\6\1\1\0\0\0",
|
|
17
|
+
"GET_SMHUB_INFO": "\x0a\6\2\1\0\0\0",
|
|
18
|
+
"GET_SMHUB_UPDATE": "\x0a\6\3\1\0<len><vlen><vers>",
|
|
19
|
+
"GET_GLOBAL_DESCRIPTIONS": "\x0a\7\1\x01\0\0\0", # Flags, Command collections
|
|
20
|
+
"GET_SMHUB_STATUS": "\x14\0\0\0\0\0\0",
|
|
21
|
+
"GET_SMHUB_FIRMWARE": "\x14\x1e\0\0\0\0\0",
|
|
22
|
+
"GET_GROUP_MODE": "\x14\2\1\x01<mod>\0\0", # <Group 0..>
|
|
23
|
+
"GET_GROUP_MODE0": "\x14\2\1\x01\0\0\0",
|
|
24
|
+
"SET_GROUP_MODE": "\x14\2\2\x01<mod>\3\0\x01<mod><arg1>", # <Group 0..><Mode>
|
|
25
|
+
"GET_ROUTER_MODES": "\x14\2\3\x01<mod>\3\0\x01<mod>\0",
|
|
26
|
+
"START_MIRROR": "\x14\x28\1\x01\0\0\0",
|
|
27
|
+
"STOP_MIRROR": "\x14\x28\2\x01\0\0\0",
|
|
28
|
+
"CHECK_COMM_STATUS": "\x14\x64\0\0\0\0\0",
|
|
29
|
+
"SET_OUTPUT_ON": "\x1e\1\1\x01<mod>\3\0\x01<mod><arg1>",
|
|
30
|
+
"SET_OUTPUT_OFF": "\x1e\1\2\x01<mod>\3\0\x01<mod><arg1>",
|
|
31
|
+
"SET_DIMMER_VALUE": "\x1e\1\3\x01<mod>\4\0\x01<mod><arg1><arg2>", # <Module><DimNo><DimVal>
|
|
32
|
+
"SET_SHUTTER_POSITION": "\x1e\1\4\x01\0\5\0\x01<mod>\1<arg1><arg2>", # <Module><RollNo><RolVal>
|
|
33
|
+
"SET_BLIND_TILT": "\x1e\1\4\x01\0\5\0\x01<mod>\2<arg1><arg2>",
|
|
34
|
+
"SET_SETPOINT_VALUE": "\x1e\2\1\x01\0\5\0\x01<mod><arg1><arg2><arg3>", # <Module><ValNo><ValL><ValH>
|
|
35
|
+
"CALL_DIR_COMMAND": "\x1e\5\1\x01<mod>\1\0<cno>", # <CmdNo>
|
|
36
|
+
"CALL_VIS_COMMAND": "\x1e\3\1\0\0\4\0\x01<mod><visl><vish>", # <Module><VisNoL><VisNoH>
|
|
37
|
+
"CALL_COLL_COMMAND": "\x1e\4\1\x01<cno>\0\0", # <CmdNo>
|
|
38
|
+
"READ_MODULE_MIRR_STATUS": "\x64\1\5\x01<mod>\0\0", # <Module>
|
|
39
|
+
"SET_FLAG_OFF": "\x1e\x0f\0\x01<mod>\1\0<fno>",
|
|
40
|
+
"SET_FLAG_ON": "\x1e\x0f\1\x01<mod>\1\0<fno>",
|
|
41
|
+
"COUNTR_UP": "\x1e\x10\2\x01<mod>\1\0<cno>",
|
|
42
|
+
"COUNTR_DOWN": "\x1e\x10\3\x01<mod>\1\0<cno>",
|
|
43
|
+
"COUNTR_VAL": "\x1e\x10\4\x01<mod>\2\0<cno><val>",
|
|
44
|
+
"SET_RGB_OFF": "\x1e\x0c\x00\x01<mod>\1\0<lno>",
|
|
45
|
+
"SET_RGB_ON": "\x1e\x0c\x01\x01<mod>\1\0<lno>",
|
|
46
|
+
"SET_RGB_COL": "\x1e\x0c\x04\x01<mod>\4\0<lno><rd><gn><bl>",
|
|
47
|
+
"SEND_MESSAGE": "\x1e\x11\3\x01<mod>\xff\xff<tim><msg>",
|
|
48
|
+
"SEND_SMS": "\x1e\x11\x0b\x01<mod>\xff\xff<sms><msg>",
|
|
49
|
+
"SET_CLIM_MODE": "\x1e\x13\x01\x01<mod>\2\0<cmode><ctl12>",
|
|
50
|
+
"GET_LAST_IR_CODE": "\x32\2\1\x01<mod>\0\0",
|
|
51
|
+
"REINIT_HUB": "\x3c\x00\x00\x01<opr>\0\0",
|
|
52
|
+
"RESTART_HUB": "\x3c\x00\x02\x01\0\0\0",
|
|
53
|
+
"REBOOT_HUB": "\x3c\x00\x03\0\0\0\0",
|
|
54
|
+
"SEND_NETWORK_INFO": "\x3c\x00\x04\0\0<len><iplen><ipv4><toklen><tok><vlen><vers>",
|
|
55
|
+
"SET_LOG_LEVEL": "\x3c\x00\x05<hdlr><lvl>\0\0", # Set logging level of console/file handler
|
|
56
|
+
"RESTART_FORWARD_TABLE": "\x3c\x01\x01\x01\0\0\0", # Weiterleitungstabelle löschen und -automatik starten
|
|
57
|
+
"GET_CURRENT_ERROR": "\x3c\x01\x02\x01\0\0\0",
|
|
58
|
+
"GET_LAST_ERROR": "\x3c\x01\x03\x01\0\0\0",
|
|
59
|
+
"REBOOT_ROUTER": "\x3c\x01\x04\x01\0\0\0",
|
|
60
|
+
"POWER_UP_CHAN": "\x3c\x01\x06\x01<msk>\0\0",
|
|
61
|
+
"POWER_DWN_CHAN": "\x3c\x01\x07\x01<msk>\0\0",
|
|
62
|
+
"DO_FW_UPDATE": "\x3c\x01\x14\x01<mod>\0\0",
|
|
63
|
+
"REBOOT_MODULE": "\x3c\x03\x01\x01<mod>\0\0", # <Module> or 0xFF for all modules
|
|
64
|
+
"SEND_MD_ID": "\x3c\x03\x09\x01<mod><len>\x00<id>",
|
|
65
|
+
}
|