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.
@@ -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,5 @@
1
+ """Habitron Client Package."""
2
+ from .client import HabitronClient, TimeoutException, HabitronError
3
+ from .const import SMHUB_COMMANDS
4
+
5
+ __all__ = ["HabitronClient", "TimeoutException", "HabitronError", "SMHUB_COMMANDS"]
@@ -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
+ }