velbus-aio 2021.8.7__py3-none-any.whl → 2025.11.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.
Files changed (200) hide show
  1. scripts/parse_specs.py +156 -0
  2. velbus_aio-2025.11.0.dist-info/METADATA +71 -0
  3. velbus_aio-2025.11.0.dist-info/RECORD +194 -0
  4. {velbus_aio-2021.8.7.dist-info → velbus_aio-2025.11.0.dist-info}/WHEEL +1 -1
  5. velbus_aio-2025.11.0.dist-info/top_level.txt +3 -0
  6. velbusaio/channels.py +443 -109
  7. velbusaio/command_registry.py +126 -13
  8. velbusaio/const.py +36 -12
  9. velbusaio/controller.py +252 -177
  10. velbusaio/discovery.py +2 -2
  11. velbusaio/exceptions.py +22 -0
  12. velbusaio/handler.py +311 -145
  13. velbusaio/helpers.py +6 -18
  14. velbusaio/message.py +46 -132
  15. velbusaio/messages/__init__.py +12 -2
  16. velbusaio/messages/blind_status.py +16 -25
  17. velbusaio/messages/bus_active.py +3 -9
  18. velbusaio/messages/bus_error_counter_status.py +3 -4
  19. velbusaio/messages/bus_error_counter_status_request.py +3 -4
  20. velbusaio/messages/bus_off.py +3 -4
  21. velbusaio/messages/channel_name_part1.py +49 -33
  22. velbusaio/messages/channel_name_part2.py +49 -33
  23. velbusaio/messages/channel_name_part3.py +49 -33
  24. velbusaio/messages/channel_name_request.py +26 -12
  25. velbusaio/messages/clear_led.py +3 -4
  26. velbusaio/messages/counter_status.py +3 -17
  27. velbusaio/messages/counter_status_request.py +6 -6
  28. velbusaio/messages/counter_value.py +44 -0
  29. velbusaio/messages/cover_down.py +4 -29
  30. velbusaio/messages/cover_off.py +5 -29
  31. velbusaio/messages/cover_position.py +4 -19
  32. velbusaio/messages/cover_up.py +4 -27
  33. velbusaio/messages/dali_device_settings.py +178 -0
  34. velbusaio/messages/dali_device_settings_request.py +53 -0
  35. velbusaio/messages/dali_dim_value_status.py +44 -0
  36. velbusaio/messages/dimmer_channel_status.py +6 -19
  37. velbusaio/messages/dimmer_status.py +14 -31
  38. velbusaio/messages/edge_set_color.py +114 -0
  39. velbusaio/messages/edge_set_custom_color.py +56 -0
  40. velbusaio/messages/fast_blinking_led.py +3 -4
  41. velbusaio/messages/forced_off.py +3 -4
  42. velbusaio/messages/forced_on.py +3 -4
  43. velbusaio/messages/interface_status_request.py +3 -4
  44. velbusaio/messages/ir_receiver_status.py +18 -0
  45. velbusaio/messages/kwh_status.py +3 -19
  46. velbusaio/messages/light_value_request.py +3 -4
  47. velbusaio/messages/memo_text.py +3 -5
  48. velbusaio/messages/memory_data.py +3 -16
  49. velbusaio/messages/memory_data_block.py +3 -4
  50. velbusaio/messages/memory_dump_request.py +3 -4
  51. velbusaio/messages/module_status.py +107 -55
  52. velbusaio/messages/module_status_request.py +7 -6
  53. velbusaio/messages/module_subtype.py +11 -19
  54. velbusaio/messages/module_type.py +132 -21
  55. velbusaio/messages/module_type_request.py +1 -0
  56. velbusaio/messages/psu_load.py +56 -0
  57. velbusaio/messages/psu_values.py +53 -0
  58. velbusaio/messages/push_button_status.py +3 -16
  59. velbusaio/messages/raw.py +74 -0
  60. velbusaio/messages/read_data_block_from_memory.py +3 -4
  61. velbusaio/messages/read_data_from_memory.py +3 -4
  62. velbusaio/messages/realtime_clock_status_request.py +3 -4
  63. velbusaio/messages/receive_buffer_full.py +3 -4
  64. velbusaio/messages/receive_ready.py +3 -4
  65. velbusaio/messages/relay_status.py +13 -42
  66. velbusaio/messages/restore_dimmer.py +33 -24
  67. velbusaio/messages/select_program.py +35 -0
  68. velbusaio/messages/sensor_settings_request.py +3 -4
  69. velbusaio/messages/sensor_temp_request.py +3 -4
  70. velbusaio/messages/sensor_temperature.py +15 -19
  71. velbusaio/messages/set_date.py +10 -30
  72. velbusaio/messages/set_daylight_saving.py +8 -24
  73. velbusaio/messages/set_dimmer.py +43 -41
  74. velbusaio/messages/set_led.py +3 -4
  75. velbusaio/messages/set_realtime_clock.py +10 -30
  76. velbusaio/messages/set_temperature.py +3 -4
  77. velbusaio/messages/slider_status.py +16 -20
  78. velbusaio/messages/slow_blinking_led.py +3 -4
  79. velbusaio/messages/start_relay_blinking_timer.py +3 -4
  80. velbusaio/messages/start_relay_timer.py +3 -4
  81. velbusaio/messages/switch_relay_off.py +3 -16
  82. velbusaio/messages/switch_relay_on.py +3 -16
  83. velbusaio/messages/switch_to_comfort.py +4 -15
  84. velbusaio/messages/switch_to_day.py +4 -15
  85. velbusaio/messages/switch_to_night.py +4 -15
  86. velbusaio/messages/switch_to_safe.py +4 -15
  87. velbusaio/messages/temp_sensor_settings_part1.py +3 -4
  88. velbusaio/messages/temp_sensor_settings_part2.py +27 -0
  89. velbusaio/messages/temp_sensor_settings_part3.py +27 -0
  90. velbusaio/messages/temp_sensor_settings_part4.py +27 -0
  91. velbusaio/messages/temp_sensor_settings_request.py +3 -4
  92. velbusaio/messages/temp_sensor_status.py +34 -35
  93. velbusaio/messages/temp_set_cooling.py +3 -13
  94. velbusaio/messages/temp_set_heating.py +3 -13
  95. velbusaio/messages/update_led_status.py +3 -4
  96. velbusaio/messages/very_fast_blinking_led.py +3 -4
  97. velbusaio/messages/write_data_to_memory.py +3 -4
  98. velbusaio/messages/write_memory_block.py +3 -4
  99. velbusaio/messages/write_module_address_and_serial_number.py +3 -4
  100. velbusaio/module.py +680 -158
  101. velbusaio/module_spec/01.json +62 -0
  102. velbusaio/module_spec/02.json +16 -0
  103. velbusaio/module_spec/03.json +23 -0
  104. velbusaio/module_spec/04.json +283 -0
  105. velbusaio/module_spec/05.json +54 -0
  106. velbusaio/module_spec/06.json +110 -0
  107. velbusaio/module_spec/07.json +16 -0
  108. velbusaio/module_spec/08.json +38 -0
  109. velbusaio/module_spec/09.json +30 -0
  110. velbusaio/module_spec/0A.json +58 -0
  111. velbusaio/module_spec/0B.json +58 -0
  112. velbusaio/module_spec/0C.json +18 -0
  113. velbusaio/module_spec/0E.json +25 -0
  114. velbusaio/module_spec/0F.json +16 -0
  115. velbusaio/module_spec/10.json +111 -0
  116. velbusaio/module_spec/11.json +111 -0
  117. velbusaio/module_spec/12.json +73 -0
  118. velbusaio/module_spec/13.json +4 -0
  119. velbusaio/module_spec/14.json +16 -0
  120. velbusaio/module_spec/15.json +83 -0
  121. velbusaio/module_spec/16.json +129 -0
  122. velbusaio/module_spec/17.json +129 -0
  123. velbusaio/module_spec/18.json +129 -0
  124. velbusaio/module_spec/1A.json +79 -0
  125. velbusaio/module_spec/1B.json +107 -0
  126. velbusaio/module_spec/1D.json +89 -0
  127. velbusaio/module_spec/1E.json +306 -0
  128. velbusaio/module_spec/1F.json +178 -0
  129. velbusaio/module_spec/20.json +178 -0
  130. velbusaio/module_spec/21.json +326 -0
  131. velbusaio/module_spec/22.json +426 -0
  132. velbusaio/module_spec/23.json +129 -0
  133. velbusaio/module_spec/24.json +30 -0
  134. velbusaio/module_spec/25.json +3 -0
  135. velbusaio/module_spec/28.json +454 -0
  136. velbusaio/module_spec/29.json +235 -0
  137. velbusaio/module_spec/2A.json +239 -0
  138. velbusaio/module_spec/2B.json +239 -0
  139. velbusaio/module_spec/2C.json +257 -0
  140. velbusaio/module_spec/2D.json +270 -0
  141. velbusaio/module_spec/2E.json +215 -0
  142. velbusaio/module_spec/2F.json +211 -0
  143. velbusaio/module_spec/30.json +58 -0
  144. velbusaio/module_spec/31.json +465 -0
  145. velbusaio/module_spec/32.json +385 -0
  146. velbusaio/module_spec/33.json +249 -0
  147. velbusaio/module_spec/34.json +313 -0
  148. velbusaio/module_spec/35.json +313 -0
  149. velbusaio/module_spec/36.json +313 -0
  150. velbusaio/module_spec/37.json +333 -0
  151. velbusaio/module_spec/38.json +111 -0
  152. velbusaio/module_spec/39.json +4 -0
  153. velbusaio/module_spec/3A.json +306 -0
  154. velbusaio/module_spec/3B.json +306 -0
  155. velbusaio/module_spec/3C.json +306 -0
  156. velbusaio/module_spec/3D.json +454 -0
  157. velbusaio/module_spec/3E.json +302 -0
  158. velbusaio/module_spec/3F.json +4 -0
  159. velbusaio/module_spec/40.json +4 -0
  160. velbusaio/module_spec/41.json +241 -0
  161. velbusaio/module_spec/42.json +4 -0
  162. velbusaio/module_spec/43.json +23 -0
  163. velbusaio/module_spec/44.json +38 -0
  164. velbusaio/module_spec/45.json +4 -0
  165. velbusaio/module_spec/48.json +111 -0
  166. velbusaio/module_spec/49.json +111 -0
  167. velbusaio/module_spec/4A.json +89 -0
  168. velbusaio/module_spec/4B.json +138 -0
  169. velbusaio/module_spec/4C.json +129 -0
  170. velbusaio/module_spec/4D.json +108 -0
  171. velbusaio/module_spec/4E.json +787 -0
  172. velbusaio/module_spec/4F.json +114 -0
  173. velbusaio/module_spec/50.json +114 -0
  174. velbusaio/module_spec/51.json +114 -0
  175. velbusaio/module_spec/52.json +456 -0
  176. velbusaio/module_spec/54.json +270 -0
  177. velbusaio/module_spec/55.json +270 -0
  178. velbusaio/module_spec/56.json +270 -0
  179. velbusaio/module_spec/57.json +260 -0
  180. velbusaio/module_spec/5A.json +4 -0
  181. velbusaio/module_spec/5B.json +4 -0
  182. velbusaio/module_spec/5C.json +90 -0
  183. velbusaio/module_spec/5F.json +78 -0
  184. velbusaio/module_spec/60.json +4 -0
  185. velbusaio/module_spec/61.json +89 -0
  186. velbusaio/module_spec/broadcast.json +67 -0
  187. velbusaio/module_spec/ignore.json +22 -0
  188. velbusaio/protocol.py +243 -0
  189. velbusaio/py.typed +0 -0
  190. velbusaio/raw_message.py +149 -0
  191. velbusaio/util.py +55 -0
  192. velbusaio/vlp_reader.py +249 -0
  193. velbus_aio-2021.8.7.dist-info/METADATA +0 -66
  194. velbus_aio-2021.8.7.dist-info/RECORD +0 -90
  195. velbus_aio-2021.8.7.dist-info/top_level.txt +0 -1
  196. velbusaio/messages/meteo_raw.py +0 -52
  197. velbusaio/module_registry.py +0 -64
  198. velbusaio/moduleprotocol/protocol.json +0 -25540
  199. velbusaio/parser.py +0 -142
  200. {velbus_aio-2021.8.7.dist-info → velbus_aio-2025.11.0.dist-info/licenses}/LICENSE +0 -0
velbusaio/protocol.py ADDED
@@ -0,0 +1,243 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import binascii
5
+ import logging
6
+ import time
7
+ import typing as t
8
+ from asyncio import transports
9
+
10
+ import backoff
11
+
12
+ from velbusaio.const import MAXIMUM_MESSAGE_SIZE, MINIMUM_MESSAGE_SIZE, SLEEP_TIME
13
+ from velbusaio.raw_message import RawMessage
14
+ from velbusaio.raw_message import create as create_message_info
15
+
16
+
17
+ def _on_write_backoff(details):
18
+ logging.debug(
19
+ f"Transport is not open, waiting {details.wait} seconds after {details.tries}"
20
+ )
21
+
22
+
23
+ class VelbusProtocol(asyncio.BufferedProtocol):
24
+ """Handles the Velbus protocol
25
+
26
+ This class is expected to be wrapped inside a VelbusConnection class object which will maintain the socket
27
+ and handle auto-reconnects"""
28
+
29
+ def __init__(
30
+ self,
31
+ message_received_callback: t.Callable[[RawMessage], t.Awaitable[None]],
32
+ connection_lost_callback=None,
33
+ ) -> None:
34
+ super().__init__()
35
+ self._log = logging.getLogger("velbus-protocol")
36
+ self._message_received_callback = message_received_callback
37
+ self._connection_lost_callback = connection_lost_callback
38
+
39
+ # everything for reading from Velbus
40
+ self._buffer = bytearray(MAXIMUM_MESSAGE_SIZE)
41
+ self._buffer_view = memoryview(self._buffer)
42
+ self._buffer_pos = 0
43
+
44
+ self._serial_buf = b""
45
+ self.transport = None
46
+
47
+ # everything for writing to Velbus
48
+ self._send_queue = asyncio.Queue()
49
+ self._write_transport_lock = asyncio.Lock()
50
+ self._writer_task = None
51
+ self._restart_writer = False
52
+ self.restart_writing()
53
+
54
+ self._closing = False
55
+
56
+ def connection_made(self, transport: transports.BaseTransport) -> None:
57
+ self.transport = transport
58
+ self._log.info("Connection established to Velbus")
59
+
60
+ self._restart_writer = True
61
+ self.restart_writing()
62
+
63
+ async def pause_writing(self) -> None:
64
+ """Pause writing."""
65
+ self._restart_writer = False
66
+ if self._writer_task:
67
+ self._send_queue.put_nowait(None)
68
+ await asyncio.sleep(0.1)
69
+
70
+ def restart_writing(self) -> None:
71
+ """Resume writing."""
72
+ if self._restart_writer and not self._write_transport_lock.locked():
73
+ self._writer_task = asyncio.ensure_future(
74
+ self._get_message_from_send_queue()
75
+ )
76
+ self._writer_task.add_done_callback(lambda _future: self.restart_writing())
77
+
78
+ def close(self) -> None:
79
+ self._closing = True
80
+ self._restart_writer = False
81
+ if self.transport:
82
+ self.transport.close()
83
+
84
+ def connection_lost(self, exc: t.Optional[Exception]) -> None:
85
+ self.transport = None
86
+
87
+ if self._closing:
88
+ return # Connection loss was expected, nothing to do here...
89
+ elif exc is None:
90
+ self._log.warning("EOF received from Velbus")
91
+ else:
92
+ self._log.error(f"Velbus connection lost: {exc!r}")
93
+
94
+ self.transport = None
95
+ asyncio.ensure_future(self.pause_writing())
96
+ if self._connection_lost_callback:
97
+ self._connection_lost_callback(exc)
98
+
99
+ # Everything read-related
100
+
101
+ def get_buffer(self, sizehint: int) -> memoryview:
102
+ return self._buffer_view[self._buffer_pos :]
103
+
104
+ def data_received(self, data: bytes) -> None:
105
+ """Receive data from the Streaming protocol.
106
+ Called when asyncio.Protocol detects received data from serial port.
107
+ """
108
+ self._serial_buf += data
109
+ self._log.debug(
110
+ "Received {nbytes} bytes from Velbus: {data_hex}".format(
111
+ nbytes=len(data),
112
+ data_hex=binascii.hexlify(self._serial_buf[: len(data)], " "),
113
+ )
114
+ )
115
+ _recheck = True
116
+
117
+ while len(self._serial_buf) > MINIMUM_MESSAGE_SIZE and _recheck:
118
+ # try to construct a Velbus message from the buffer
119
+
120
+ _remaining_buf = self._serial_buf[MAXIMUM_MESSAGE_SIZE:]
121
+ msg, remaining_data = create_message_info(
122
+ bytearray(self._serial_buf[:MAXIMUM_MESSAGE_SIZE])
123
+ )
124
+
125
+ if msg is not None:
126
+ asyncio.ensure_future(self._process_message(msg))
127
+ _recheck = True
128
+ else:
129
+ _recheck = False
130
+ self._serial_buf = bytes(remaining_data) + _remaining_buf
131
+
132
+ def buffer_updated(self, nbytes: int) -> None:
133
+ """Receive data from the Buffered Streaming protocol.
134
+ Called when asyncio.BufferedProtocol detects received data from network.
135
+ """
136
+ self._buffer_pos += nbytes
137
+ # self._log.debug(
138
+ # "Received {nbytes} bytes from Velbus: {data_hex}".format(
139
+ # nbytes=nbytes,
140
+ # data_hex=binascii.hexlify(
141
+ # self._buffer[self._buffer_pos - nbytes : self._buffer_pos], " "
142
+ # ),
143
+ # )
144
+ # )
145
+
146
+ if self._buffer_pos > MINIMUM_MESSAGE_SIZE:
147
+ # try to construct a Velbus message from the buffer
148
+ msg, remaining_data = create_message_info(self._buffer)
149
+
150
+ if msg is not None:
151
+ asyncio.ensure_future(self._process_message(msg))
152
+
153
+ self._new_buffer(remaining_data)
154
+
155
+ def _new_buffer(self, remaining_data=None) -> None:
156
+ new_buffer = bytearray(MAXIMUM_MESSAGE_SIZE)
157
+ if remaining_data:
158
+ new_buffer[: len(remaining_data)] = remaining_data
159
+
160
+ self._buffer = new_buffer
161
+ self._buffer_pos = len(remaining_data) if remaining_data else 0
162
+ self._buffer_view = memoryview(self._buffer)
163
+
164
+ async def _process_message(self, msg: RawMessage) -> None:
165
+ # self._log.debug(f"RX: {msg}")
166
+ await self._message_received_callback(msg)
167
+
168
+ # Everything write-related
169
+
170
+ async def write_auth_key(self, authkey: str) -> None:
171
+ self._log.debug("TX: authentication key")
172
+ if not self.transport.is_closing():
173
+ self.transport.write(authkey.encode("utf-8"))
174
+
175
+ async def send_message(self, msg: RawMessage) -> None:
176
+ self._send_queue.put_nowait(msg)
177
+
178
+ async def _get_message_from_send_queue(self) -> None:
179
+ self._log.debug("Starting Velbus write message from send queue")
180
+ self._log.debug("Acquiring write lock")
181
+ await self._write_transport_lock.acquire()
182
+ while self._restart_writer:
183
+ # wait for an item from the queue
184
+ msg_info: RawMessage | None = await self._send_queue.get()
185
+ if msg_info is None:
186
+ self._restart_writer = False
187
+ return
188
+ message_sent = False
189
+ try:
190
+ start_time = time.perf_counter()
191
+ while not message_sent:
192
+ message_sent = await self._write_message(msg_info)
193
+ send_time = time.perf_counter() - start_time
194
+
195
+ self._send_queue.task_done() # indicate that the item of the queue has been processed
196
+
197
+ queue_sleep_time = self._calculate_queue_sleep_time(msg_info, send_time)
198
+ await asyncio.sleep(queue_sleep_time)
199
+
200
+ except (asyncio.CancelledError, GeneratorExit) as exc:
201
+ if not self._closing:
202
+ self._log.error(f"Stopping Velbus writer due to {exc!r}")
203
+ self._restart_writer = False
204
+ except Exception as exc:
205
+ self._log.error(f"Restarting Velbus writer due to {exc!r}")
206
+ self._restart_writer = True
207
+ if self._write_transport_lock.locked():
208
+ self._write_transport_lock.release()
209
+ self._log.debug("Ending Velbus write message from send queue")
210
+
211
+ @staticmethod
212
+ def _calculate_queue_sleep_time(msg_info, send_time):
213
+ sleep_time = SLEEP_TIME
214
+
215
+ if msg_info.rtr:
216
+ sleep_time = SLEEP_TIME # this is a scan command. We could be quicker?
217
+
218
+ if msg_info.command == 0xEF:
219
+ # 'channel name request' command provokes in worst case 99 answer packets from VMBGPOD
220
+ sleep_time = SLEEP_TIME * 33 # TODO make this adaptable on module_type
221
+
222
+ if send_time > sleep_time:
223
+ return 0 # no need to wait, we are already late
224
+ else:
225
+ return sleep_time - send_time
226
+
227
+ @backoff.on_predicate(
228
+ backoff.expo,
229
+ lambda is_sent: not is_sent,
230
+ max_tries=10,
231
+ on_backoff=_on_write_backoff,
232
+ )
233
+ async def _write_message(self, msg: RawMessage) -> bool:
234
+ self._log.debug(f"TX: {msg}")
235
+ if not self.transport.is_closing():
236
+ self.transport.write(msg.to_bytes())
237
+ return True
238
+ else:
239
+ return False
240
+
241
+ async def wait_on_all_messages_sent_async(self) -> None:
242
+ self._log.debug("Waiting on all messages sent")
243
+ await self._send_queue.join()
velbusaio/py.typed ADDED
File without changes
@@ -0,0 +1,149 @@
1
+ import binascii
2
+ import logging
3
+ from typing import NamedTuple, Optional, Tuple
4
+
5
+ from velbusaio.const import (
6
+ END_BYTE,
7
+ HEADER_LENGTH,
8
+ MAXIMUM_MESSAGE_SIZE,
9
+ MINIMUM_MESSAGE_SIZE,
10
+ NO_RTR,
11
+ PRIORITIES,
12
+ RTR,
13
+ START_BYTE,
14
+ TAIL_LENGTH,
15
+ )
16
+ from velbusaio.util import checksum
17
+ from velbusaio.util import checksum as calculate_checksum
18
+
19
+
20
+ class RawMessage(NamedTuple):
21
+ priority: int
22
+ address: int
23
+ rtr: bool
24
+ data: bytes
25
+
26
+ @property
27
+ def command(self) -> Optional[int]:
28
+ return self.data[0] if len(self.data) > 0 else None
29
+
30
+ @property
31
+ def data_only(self) -> Optional[bytes]:
32
+ return self.data[1:] if len(self.data) > 1 else None
33
+
34
+ def to_bytes(self) -> bytes:
35
+ # create header:
36
+ header_bytes = bytes(
37
+ [
38
+ START_BYTE,
39
+ self.priority,
40
+ self.address,
41
+ (RTR if self.rtr else NO_RTR) | len(self.data),
42
+ ]
43
+ )
44
+
45
+ tail_bytes = bytes([checksum(header_bytes + self.data), END_BYTE])
46
+
47
+ return header_bytes + self.data + tail_bytes
48
+
49
+ def __repr__(self) -> str:
50
+ return (
51
+ f"RawMessage(priority={self.priority:02x}, address={self.address:02x},"
52
+ f" rtr={self.rtr!r}, command={self.command},"
53
+ f" data={binascii.hexlify(self.data, ' ')})"
54
+ )
55
+
56
+
57
+ def create(rawmessage: bytearray) -> Tuple[Optional[RawMessage], bytearray]:
58
+ rawmessage = _trim_buffer_garbage(rawmessage)
59
+
60
+ while True:
61
+ if len(rawmessage) < MINIMUM_MESSAGE_SIZE:
62
+ logging.debug("Buffer does not yet contain a full message")
63
+ return None, rawmessage
64
+
65
+ try:
66
+ return _parse(rawmessage)
67
+ except ParseError:
68
+ logging.error(
69
+ f"Could not parse the message {binascii.hexlify(rawmessage)}. Truncating invalid data."
70
+ )
71
+ rawmessage = _trim_buffer_garbage(
72
+ rawmessage[1:]
73
+ ) # try to find possible start of a message
74
+
75
+
76
+ class ParseError(Exception):
77
+ pass
78
+
79
+
80
+ def _parse(rawmessage: bytearray) -> Tuple[Optional[RawMessage], bytearray]:
81
+ if len(rawmessage) < MINIMUM_MESSAGE_SIZE or len(rawmessage) > MAXIMUM_MESSAGE_SIZE:
82
+ raise ValueError("Received a raw message with an illegal lemgth")
83
+ if rawmessage[0] != START_BYTE:
84
+ raise ValueError("Received a raw message with the wrong startbyte")
85
+
86
+ priority = rawmessage[1]
87
+ if priority not in PRIORITIES:
88
+ raise ParseError(
89
+ f"Invalid priority byte: {priority:02x} in {binascii.hexlify(rawmessage)}"
90
+ )
91
+
92
+ address = rawmessage[2]
93
+
94
+ rtr = rawmessage[3] & RTR == RTR # high nibble of the 4th byte
95
+ data_size = rawmessage[3] & 0x0F # low nibble of the 4th byte
96
+
97
+ if HEADER_LENGTH + data_size + TAIL_LENGTH > len(rawmessage):
98
+ return (
99
+ None,
100
+ rawmessage,
101
+ ) # the full package is not available in the current buffer
102
+
103
+ if rawmessage[HEADER_LENGTH + data_size + 1] != END_BYTE:
104
+ raise ParseError(f"Invalid end byte in {binascii.hexlify(rawmessage)}")
105
+
106
+ checksum = rawmessage[HEADER_LENGTH + data_size]
107
+
108
+ calculated_checksum = calculate_checksum(rawmessage[: HEADER_LENGTH + data_size])
109
+
110
+ if calculated_checksum != checksum:
111
+ raise ParseError(
112
+ f"Invalid checksum: expected {calculated_checksum:02x},"
113
+ f" but got {checksum:02x} in {binascii.hexlify(rawmessage)}"
114
+ )
115
+
116
+ data = bytes(rawmessage[HEADER_LENGTH : HEADER_LENGTH + data_size])
117
+
118
+ return (
119
+ RawMessage(priority, address, rtr, data),
120
+ rawmessage[HEADER_LENGTH + data_size + TAIL_LENGTH :],
121
+ )
122
+
123
+
124
+ def _trim_buffer_garbage(rawmessage: bytearray) -> bytearray:
125
+ """
126
+ Remove leading garbage bytes from a byte stream.
127
+ """
128
+
129
+ # A proper message byte stream begins with 0x0F.
130
+ if rawmessage and rawmessage[0] != START_BYTE:
131
+ start_index = rawmessage.find(START_BYTE)
132
+ if start_index > -1:
133
+ # logging.debug(
134
+ # "Trimming leading garbage from buffer content: {buffer} becomes {new_buffer}".format(
135
+ # buffer=binascii.hexlify(rawmessage),
136
+ # new_buffer=binascii.hexlify(rawmessage[start_index:]),
137
+ # )
138
+ # )
139
+ return rawmessage[start_index:]
140
+ else:
141
+ logging.debug(
142
+ "Trimming whole buffer as it does not contain the start byte: {buffer}".format(
143
+ buffer=binascii.hexlify(rawmessage)
144
+ )
145
+ )
146
+ return []
147
+
148
+ else:
149
+ return rawmessage
velbusaio/util.py ADDED
@@ -0,0 +1,55 @@
1
+ """
2
+ Some common utils.
3
+ """
4
+
5
+ from typing import Union
6
+
7
+ from velbusaio.const import MAXIMUM_MESSAGE_SIZE, MINIMUM_MESSAGE_SIZE
8
+
9
+
10
+ # Copyright (c) 2017 Thomas Delaet
11
+ # Copied from python-velbus (https://github.com/thomasdelaet/python-velbus)
12
+ def checksum(data: Union[bytes, bytearray]) -> int:
13
+ if len(data) < MINIMUM_MESSAGE_SIZE - 2:
14
+ raise ValueError("The message is shorter then expected")
15
+ if len(data) > MAXIMUM_MESSAGE_SIZE - 2:
16
+ raise ValueError("The message is longer then expected")
17
+ __checksum = 0
18
+ for data_byte in data:
19
+ __checksum += data_byte
20
+ __checksum = -(__checksum % 256) + 256
21
+ return __checksum % 256
22
+
23
+
24
+ class VelbusException(Exception):
25
+ """Velbus Exception."""
26
+
27
+ def __init__(self, value):
28
+ Exception.__init__(self)
29
+ self.value = value
30
+
31
+ def __str__(self) -> str:
32
+ return repr(self.value)
33
+
34
+
35
+ class MessageParseException(Exception):
36
+ pass
37
+
38
+
39
+ class BitSet:
40
+ def __init__(self, value: int):
41
+ self._value = value
42
+
43
+ def __getitem__(self, idx: int) -> bool:
44
+ if idx > 8 or idx <= 0:
45
+ raise ValueError("The bitSet id is not within expected range 0 < id < 8")
46
+ return bool((1 << idx) & self._value)
47
+
48
+ def __setitem__(self, idx: int, value: bool) -> None:
49
+ if idx > 8 or idx <= 0:
50
+ raise ValueError("The bitSet id is not within expected range 0 < id < 8")
51
+ mask = (0xFF ^ (1 << idx)) & self._value
52
+ self._value = mask & (value << idx)
53
+
54
+ def __len__(self) -> int:
55
+ return 8 # a bitset represents one byte
@@ -0,0 +1,249 @@
1
+ import importlib.resources
2
+ import json
3
+ import logging
4
+ import sys
5
+
6
+ from aiofile import async_open
7
+ from bs4 import BeautifulSoup
8
+
9
+ from velbusaio.command_registry import MODULE_DIRECTORY
10
+ from velbusaio.helpers import h2
11
+
12
+
13
+ class VlpFile:
14
+
15
+ def __init__(self, file_path) -> None:
16
+ self._file_path = file_path
17
+ self._modules = []
18
+ self._log = logging.getLogger("velbus-vlpFile")
19
+
20
+ def get(self) -> dict:
21
+ return self._modules
22
+
23
+ async def read(self) -> None:
24
+ async with async_open(self._file_path) as file:
25
+ xml_content = await file.read()
26
+ _soup = BeautifulSoup(xml_content, "xml")
27
+ for module in _soup.find_all("Module"):
28
+ mod = vlpModule(
29
+ module.find("Caption").get_text(),
30
+ module["address"],
31
+ module["build"],
32
+ module["serial"],
33
+ module["type"],
34
+ module.find("Memory").get_text(),
35
+ )
36
+ self._modules.append(mod)
37
+ await mod.parse()
38
+ self._modules.sort(key=lambda mod: mod.get_decimal_addr())
39
+
40
+ def dump(self) -> None:
41
+ for m in self._modules:
42
+ print(f"Module {m.get_decimal_addr()}: {m._name}, type {m._type_id}")
43
+ for key, value in m._channels.items():
44
+ name = value["Name"]
45
+ print(f" {key} => {name}")
46
+
47
+
48
+ class vlpModule:
49
+
50
+ def __init__(self, name, addresses, build, serial, type, memory) -> None:
51
+ self._name = name
52
+ self._addresses = addresses
53
+ self._build = build
54
+ self._serial = serial
55
+ self._type = type
56
+ self._memory = memory
57
+ self._spec = {}
58
+ self._channels = {}
59
+ self._type_id = next(
60
+ (key for key, value in MODULE_DIRECTORY.items() if value == self._type),
61
+ None,
62
+ )
63
+ self._log = logging.getLogger("velbus-vlpFile")
64
+ self._log.info(
65
+ f"=> Created vlpModule address: {self._addresses} type: {self._type} ({self._type_id})"
66
+ )
67
+
68
+ def get(self) -> None:
69
+ print(self._channels)
70
+ print(self)
71
+
72
+ def get_addr(self) -> str:
73
+ return self._addresses
74
+
75
+ def get_name(self) -> str:
76
+ return self._name
77
+
78
+ def get_type(self) -> int | None:
79
+ return self._type_id
80
+
81
+ def get_serial(self) -> str:
82
+ return self._serial
83
+
84
+ def get_memory(self) -> str:
85
+ return self._memory
86
+
87
+ def get_build(self) -> str:
88
+ return self._build
89
+
90
+ def get_channels(self) -> dict:
91
+ return self._channels
92
+
93
+ def __str__(self):
94
+ return f"vlpModule(name={self._name}, addresses={self._addresses}, build={self._build}, serial={self._serial}, type={self._type})"
95
+
96
+ def get_decimal_addr(
97
+ self,
98
+ ) -> int: # lgor: get the numeric value of primary module address
99
+ addr = self._addresses.split(",")[0]
100
+ return int(addr, 16)
101
+
102
+ async def parse(self) -> None:
103
+ await self._load_module_spec()
104
+
105
+ if "Memory" not in self._spec:
106
+ self._log.debug(" => no Memory locations found")
107
+ return None
108
+
109
+ # channel names
110
+ self._channels = self._spec.get("Channels", {})
111
+ for addr, chan in self._channels.items():
112
+ self._log.debug(f" => Processing channel {addr}:")
113
+ if ("Editable" in chan) and (chan["Editable"] == "yes"):
114
+ self._log.debug(f" => channel {addr} is editable, getting name")
115
+ name = self._get_channel_name(int(addr))
116
+ if name:
117
+ self._log.debug(f" => got name '{name}' for channel {addr}")
118
+ self._channels[addr]["Name"] = name
119
+ self._channels[addr]["_is_loaded"] = True
120
+
121
+ # extra
122
+ self._load_extra_data()
123
+
124
+ def _load_extra_data(self) -> None:
125
+ self._log.debug(" => Getting extra data")
126
+ if "Extras" not in self._spec["Memory"]:
127
+ self._log.debug(" => no Extra Memory locations found")
128
+ return None
129
+ for addr, extra in self._spec["Memory"]["Extras"].items():
130
+ byte_data = bytes.fromhex(self._read_from_memory(addr))
131
+ self._log.debug(
132
+ f" => got extra data {byte_data.hex().upper()} from address {addr}"
133
+ )
134
+ if "Translate" in extra:
135
+ translation_found = False
136
+ for translate_key, translate_value in extra["Translate"].items():
137
+ if translate_key.startswith("%"):
138
+ # Binary pattern matching
139
+ if self._match_binary_pattern(translate_key, byte_data):
140
+ self._log.debug(
141
+ f" => Binary pattern {translate_key} matched, value: {translate_value}"
142
+ )
143
+ self._channels[translate_value["Channel"]][
144
+ translate_value["SubName"]
145
+ ] = translate_value["Value"]
146
+ translation_found = True
147
+ else:
148
+ # Direct value matching (existing behavior for integer keys)
149
+ try:
150
+ int_key = int(translate_key)
151
+ if len(byte_data) > 0 and byte_data[0] == int_key:
152
+ self._log.debug(
153
+ f" => Direct match for value {int_key}: {translate_value}"
154
+ )
155
+ translation_found = True
156
+ except ValueError:
157
+ # Not an integer key, skip
158
+ continue
159
+ if not translation_found:
160
+ self._log.error(
161
+ f" => No translation found for data {byte_data.hex().upper()}"
162
+ )
163
+
164
+ def _match_binary_pattern(self, pattern: str, byte_data: bytes) -> bool:
165
+ """
166
+ Match a binary pattern like %......00 against byte data.
167
+ % indicates binary pattern
168
+ . means don't care bit
169
+ 0/1 are specific bits that must match
170
+ """
171
+ if not pattern.startswith("%"):
172
+ return False
173
+
174
+ # Remove the % prefix
175
+ binary_pattern = pattern[1:]
176
+
177
+ # Convert byte_data to binary string (without '0b' prefix)
178
+ if len(byte_data) == 0:
179
+ return False
180
+
181
+ # Take the first byte for pattern matching
182
+ byte_value = byte_data[0]
183
+ binary_data = format(byte_value, "08b")
184
+
185
+ # Check if pattern length matches
186
+ if len(binary_pattern) != len(binary_data):
187
+ return False
188
+
189
+ # Check each bit position
190
+ for i, (pattern_bit, data_bit) in enumerate(zip(binary_pattern, binary_data)):
191
+ if pattern_bit == ".":
192
+ # Don't care bit, skip
193
+ continue
194
+ elif pattern_bit != data_bit:
195
+ # Specific bit must match
196
+ return False
197
+
198
+ return True
199
+
200
+ def _get_channel_name(self, chan: int) -> str | None:
201
+ if "Channels" not in self._spec["Memory"]:
202
+ self._log.debug(" => no Channels Memory locations found")
203
+ return None
204
+ dchan = format(chan, "02d")
205
+ if dchan not in self._spec["Memory"]["Channels"]:
206
+ self._log.debug(f" => no chan {chan} Memory locations found")
207
+ return None
208
+ byte_data = bytes.fromhex(
209
+ self._read_from_memory(self._spec["Memory"]["Channels"][dchan]).replace(
210
+ "FF", ""
211
+ )
212
+ )
213
+ try:
214
+ name = byte_data.decode("ascii")
215
+ except UnicodeDecodeError as e:
216
+ self._log.error(f" => UnicodeDecodeError: {e}")
217
+ name = byte_data
218
+ finally:
219
+ return name
220
+
221
+ async def _load_module_spec(self) -> None:
222
+ self._log.debug(f" => Load module spec for {self._type_id}")
223
+ if sys.version_info >= (3, 13):
224
+ with importlib.resources.path(
225
+ __name__, f"module_spec/{h2(self._type_id)}.json"
226
+ ) as fspath:
227
+ async with async_open(fspath) as protocol_file:
228
+ self._spec = json.loads(await protocol_file.read())
229
+ else:
230
+ async with async_open(
231
+ str(
232
+ importlib.resources.files(__name__.split(".")[0]).joinpath(
233
+ f"module_spec/{h2(self._type_id)}.json"
234
+ )
235
+ )
236
+ ) as protocol_file:
237
+ self._spec = json.loads(await protocol_file.read())
238
+
239
+ def _read_from_memory(self, address_range) -> str | None:
240
+ # its a single address
241
+ if "-" not in address_range:
242
+ start = int(address_range, 16) * 2
243
+ end = (int(address_range, 16) + 1) * 2
244
+ return self._memory[start:end]
245
+ # its a range
246
+ start_str, end_str = address_range.split("-")
247
+ start = int(start_str, 16) * 2
248
+ end = (int(end_str, 16) + 1) * 2
249
+ return self._memory[start:end]