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.
- scripts/parse_specs.py +156 -0
- velbus_aio-2025.11.0.dist-info/METADATA +71 -0
- velbus_aio-2025.11.0.dist-info/RECORD +194 -0
- {velbus_aio-2021.8.7.dist-info → velbus_aio-2025.11.0.dist-info}/WHEEL +1 -1
- velbus_aio-2025.11.0.dist-info/top_level.txt +3 -0
- velbusaio/channels.py +443 -109
- velbusaio/command_registry.py +126 -13
- velbusaio/const.py +36 -12
- velbusaio/controller.py +252 -177
- velbusaio/discovery.py +2 -2
- velbusaio/exceptions.py +22 -0
- velbusaio/handler.py +311 -145
- velbusaio/helpers.py +6 -18
- velbusaio/message.py +46 -132
- velbusaio/messages/__init__.py +12 -2
- velbusaio/messages/blind_status.py +16 -25
- velbusaio/messages/bus_active.py +3 -9
- velbusaio/messages/bus_error_counter_status.py +3 -4
- velbusaio/messages/bus_error_counter_status_request.py +3 -4
- velbusaio/messages/bus_off.py +3 -4
- velbusaio/messages/channel_name_part1.py +49 -33
- velbusaio/messages/channel_name_part2.py +49 -33
- velbusaio/messages/channel_name_part3.py +49 -33
- velbusaio/messages/channel_name_request.py +26 -12
- velbusaio/messages/clear_led.py +3 -4
- velbusaio/messages/counter_status.py +3 -17
- velbusaio/messages/counter_status_request.py +6 -6
- velbusaio/messages/counter_value.py +44 -0
- velbusaio/messages/cover_down.py +4 -29
- velbusaio/messages/cover_off.py +5 -29
- velbusaio/messages/cover_position.py +4 -19
- velbusaio/messages/cover_up.py +4 -27
- velbusaio/messages/dali_device_settings.py +178 -0
- velbusaio/messages/dali_device_settings_request.py +53 -0
- velbusaio/messages/dali_dim_value_status.py +44 -0
- velbusaio/messages/dimmer_channel_status.py +6 -19
- velbusaio/messages/dimmer_status.py +14 -31
- velbusaio/messages/edge_set_color.py +114 -0
- velbusaio/messages/edge_set_custom_color.py +56 -0
- velbusaio/messages/fast_blinking_led.py +3 -4
- velbusaio/messages/forced_off.py +3 -4
- velbusaio/messages/forced_on.py +3 -4
- velbusaio/messages/interface_status_request.py +3 -4
- velbusaio/messages/ir_receiver_status.py +18 -0
- velbusaio/messages/kwh_status.py +3 -19
- velbusaio/messages/light_value_request.py +3 -4
- velbusaio/messages/memo_text.py +3 -5
- velbusaio/messages/memory_data.py +3 -16
- velbusaio/messages/memory_data_block.py +3 -4
- velbusaio/messages/memory_dump_request.py +3 -4
- velbusaio/messages/module_status.py +107 -55
- velbusaio/messages/module_status_request.py +7 -6
- velbusaio/messages/module_subtype.py +11 -19
- velbusaio/messages/module_type.py +132 -21
- velbusaio/messages/module_type_request.py +1 -0
- velbusaio/messages/psu_load.py +56 -0
- velbusaio/messages/psu_values.py +53 -0
- velbusaio/messages/push_button_status.py +3 -16
- velbusaio/messages/raw.py +74 -0
- velbusaio/messages/read_data_block_from_memory.py +3 -4
- velbusaio/messages/read_data_from_memory.py +3 -4
- velbusaio/messages/realtime_clock_status_request.py +3 -4
- velbusaio/messages/receive_buffer_full.py +3 -4
- velbusaio/messages/receive_ready.py +3 -4
- velbusaio/messages/relay_status.py +13 -42
- velbusaio/messages/restore_dimmer.py +33 -24
- velbusaio/messages/select_program.py +35 -0
- velbusaio/messages/sensor_settings_request.py +3 -4
- velbusaio/messages/sensor_temp_request.py +3 -4
- velbusaio/messages/sensor_temperature.py +15 -19
- velbusaio/messages/set_date.py +10 -30
- velbusaio/messages/set_daylight_saving.py +8 -24
- velbusaio/messages/set_dimmer.py +43 -41
- velbusaio/messages/set_led.py +3 -4
- velbusaio/messages/set_realtime_clock.py +10 -30
- velbusaio/messages/set_temperature.py +3 -4
- velbusaio/messages/slider_status.py +16 -20
- velbusaio/messages/slow_blinking_led.py +3 -4
- velbusaio/messages/start_relay_blinking_timer.py +3 -4
- velbusaio/messages/start_relay_timer.py +3 -4
- velbusaio/messages/switch_relay_off.py +3 -16
- velbusaio/messages/switch_relay_on.py +3 -16
- velbusaio/messages/switch_to_comfort.py +4 -15
- velbusaio/messages/switch_to_day.py +4 -15
- velbusaio/messages/switch_to_night.py +4 -15
- velbusaio/messages/switch_to_safe.py +4 -15
- velbusaio/messages/temp_sensor_settings_part1.py +3 -4
- velbusaio/messages/temp_sensor_settings_part2.py +27 -0
- velbusaio/messages/temp_sensor_settings_part3.py +27 -0
- velbusaio/messages/temp_sensor_settings_part4.py +27 -0
- velbusaio/messages/temp_sensor_settings_request.py +3 -4
- velbusaio/messages/temp_sensor_status.py +34 -35
- velbusaio/messages/temp_set_cooling.py +3 -13
- velbusaio/messages/temp_set_heating.py +3 -13
- velbusaio/messages/update_led_status.py +3 -4
- velbusaio/messages/very_fast_blinking_led.py +3 -4
- velbusaio/messages/write_data_to_memory.py +3 -4
- velbusaio/messages/write_memory_block.py +3 -4
- velbusaio/messages/write_module_address_and_serial_number.py +3 -4
- velbusaio/module.py +680 -158
- velbusaio/module_spec/01.json +62 -0
- velbusaio/module_spec/02.json +16 -0
- velbusaio/module_spec/03.json +23 -0
- velbusaio/module_spec/04.json +283 -0
- velbusaio/module_spec/05.json +54 -0
- velbusaio/module_spec/06.json +110 -0
- velbusaio/module_spec/07.json +16 -0
- velbusaio/module_spec/08.json +38 -0
- velbusaio/module_spec/09.json +30 -0
- velbusaio/module_spec/0A.json +58 -0
- velbusaio/module_spec/0B.json +58 -0
- velbusaio/module_spec/0C.json +18 -0
- velbusaio/module_spec/0E.json +25 -0
- velbusaio/module_spec/0F.json +16 -0
- velbusaio/module_spec/10.json +111 -0
- velbusaio/module_spec/11.json +111 -0
- velbusaio/module_spec/12.json +73 -0
- velbusaio/module_spec/13.json +4 -0
- velbusaio/module_spec/14.json +16 -0
- velbusaio/module_spec/15.json +83 -0
- velbusaio/module_spec/16.json +129 -0
- velbusaio/module_spec/17.json +129 -0
- velbusaio/module_spec/18.json +129 -0
- velbusaio/module_spec/1A.json +79 -0
- velbusaio/module_spec/1B.json +107 -0
- velbusaio/module_spec/1D.json +89 -0
- velbusaio/module_spec/1E.json +306 -0
- velbusaio/module_spec/1F.json +178 -0
- velbusaio/module_spec/20.json +178 -0
- velbusaio/module_spec/21.json +326 -0
- velbusaio/module_spec/22.json +426 -0
- velbusaio/module_spec/23.json +129 -0
- velbusaio/module_spec/24.json +30 -0
- velbusaio/module_spec/25.json +3 -0
- velbusaio/module_spec/28.json +454 -0
- velbusaio/module_spec/29.json +235 -0
- velbusaio/module_spec/2A.json +239 -0
- velbusaio/module_spec/2B.json +239 -0
- velbusaio/module_spec/2C.json +257 -0
- velbusaio/module_spec/2D.json +270 -0
- velbusaio/module_spec/2E.json +215 -0
- velbusaio/module_spec/2F.json +211 -0
- velbusaio/module_spec/30.json +58 -0
- velbusaio/module_spec/31.json +465 -0
- velbusaio/module_spec/32.json +385 -0
- velbusaio/module_spec/33.json +249 -0
- velbusaio/module_spec/34.json +313 -0
- velbusaio/module_spec/35.json +313 -0
- velbusaio/module_spec/36.json +313 -0
- velbusaio/module_spec/37.json +333 -0
- velbusaio/module_spec/38.json +111 -0
- velbusaio/module_spec/39.json +4 -0
- velbusaio/module_spec/3A.json +306 -0
- velbusaio/module_spec/3B.json +306 -0
- velbusaio/module_spec/3C.json +306 -0
- velbusaio/module_spec/3D.json +454 -0
- velbusaio/module_spec/3E.json +302 -0
- velbusaio/module_spec/3F.json +4 -0
- velbusaio/module_spec/40.json +4 -0
- velbusaio/module_spec/41.json +241 -0
- velbusaio/module_spec/42.json +4 -0
- velbusaio/module_spec/43.json +23 -0
- velbusaio/module_spec/44.json +38 -0
- velbusaio/module_spec/45.json +4 -0
- velbusaio/module_spec/48.json +111 -0
- velbusaio/module_spec/49.json +111 -0
- velbusaio/module_spec/4A.json +89 -0
- velbusaio/module_spec/4B.json +138 -0
- velbusaio/module_spec/4C.json +129 -0
- velbusaio/module_spec/4D.json +108 -0
- velbusaio/module_spec/4E.json +787 -0
- velbusaio/module_spec/4F.json +114 -0
- velbusaio/module_spec/50.json +114 -0
- velbusaio/module_spec/51.json +114 -0
- velbusaio/module_spec/52.json +456 -0
- velbusaio/module_spec/54.json +270 -0
- velbusaio/module_spec/55.json +270 -0
- velbusaio/module_spec/56.json +270 -0
- velbusaio/module_spec/57.json +260 -0
- velbusaio/module_spec/5A.json +4 -0
- velbusaio/module_spec/5B.json +4 -0
- velbusaio/module_spec/5C.json +90 -0
- velbusaio/module_spec/5F.json +78 -0
- velbusaio/module_spec/60.json +4 -0
- velbusaio/module_spec/61.json +89 -0
- velbusaio/module_spec/broadcast.json +67 -0
- velbusaio/module_spec/ignore.json +22 -0
- velbusaio/protocol.py +243 -0
- velbusaio/py.typed +0 -0
- velbusaio/raw_message.py +149 -0
- velbusaio/util.py +55 -0
- velbusaio/vlp_reader.py +249 -0
- velbus_aio-2021.8.7.dist-info/METADATA +0 -66
- velbus_aio-2021.8.7.dist-info/RECORD +0 -90
- velbus_aio-2021.8.7.dist-info/top_level.txt +0 -1
- velbusaio/messages/meteo_raw.py +0 -52
- velbusaio/module_registry.py +0 -64
- velbusaio/moduleprotocol/protocol.json +0 -25540
- velbusaio/parser.py +0 -142
- {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
|
velbusaio/raw_message.py
ADDED
|
@@ -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
|
velbusaio/vlp_reader.py
ADDED
|
@@ -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]
|