velbus-aio 2025.4.2__py3-none-any.whl → 2025.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of velbus-aio might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: velbus-aio
3
- Version: 2025.4.2
3
+ Version: 2025.8.0
4
4
  Summary: Open-source home automation platform running on Python 3.
5
5
  Author-email: Maikel Punie <maikel.punie@gmail.com>
6
6
  License: MIT
@@ -1,20 +1,20 @@
1
- velbus_aio-2025.4.2.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
1
+ velbus_aio-2025.8.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
2
2
  velbusaio/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
3
  velbusaio/channels.py,sha256=pR-y7Res_MAPSFEM62eLx844HU0EjX7SyyEpJT4YU74,23556
4
4
  velbusaio/command_registry.py,sha256=nlx4__0ASr5WurOYLC-HEqswlg_qm3Y5trs4V8FhnvU,5350
5
5
  velbusaio/const.py,sha256=aHClMaMS6NINTCrjeRwLbVzdvS91VgFKM8j61ks5tn0,1862
6
- velbusaio/controller.py,sha256=Ohc-18ydnm76-5fOD_b24Aa_uYu6CdkI3ulfhska5og,8887
6
+ velbusaio/controller.py,sha256=zvkd8JWHZEKk9rg0GOAGRR3OS97B2ugQkeKQh-ucaFA,9111
7
7
  velbusaio/discovery.py,sha256=Px6qoZl4QhF17aMz6JxstCORBpLzZGWEK9h4Vyvg57o,1649
8
8
  velbusaio/exceptions.py,sha256=FHkXaM3dK5Gkk-QGAf9dLE3FPlCU2FRZWUyY-4KRNnA,515
9
- velbusaio/handler.py,sha256=gkRikfrTzekL0hFQSUV0AjVuMoUY1DNONEhbRBKyRko,11901
9
+ velbusaio/handler.py,sha256=7F5E3vkkptk6alOTg0dIpyF5Uqd6xpzXoJMXkmR9r58,13126
10
10
  velbusaio/helpers.py,sha256=iqpoereRH4JY5WAkozIqWvXWyRmhko-J-UGXDylFyEM,2537
11
- velbusaio/message.py,sha256=_MRuI4XnMcqeduCDJ3AwhAPL1B8VMstFDnjjLo8QuxM,5018
12
- velbusaio/module.py,sha256=y5uegbCl0vowBqoeagycRjP6FPLDPaGJ4ZJeW22wMqA,37631
13
- velbusaio/protocol.py,sha256=RIpdL0vPlL4qE4bC8sEZSD0SRS4tWtR9MdzjTPSd2zs,8002
11
+ velbusaio/message.py,sha256=Qzdx6Qrk-DptRnkdfirp5jEKA4hoANtPHfAxvWHzpvU,5305
12
+ velbusaio/module.py,sha256=kVaOp8ZLTSA4OMyTxKx4fJDAQw39teIxdtLagWsrCdc,38975
13
+ velbusaio/protocol.py,sha256=59Qdw2bZ9wL4_XmxvqRD8R1LJoWfrwRRaCOM3KGa0x0,8808
14
14
  velbusaio/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
15
  velbusaio/raw_message.py,sha256=ODoTPVAvyXXQSMXxtQO1U5OxcoQ4W8JJN5fotEDGSTo,4690
16
16
  velbusaio/util.py,sha256=FW6YCiPYWOCgqHDs8-LbzME0h81mqftYVG0qqZ-oo7Y,1568
17
- velbusaio/vlp_reader.py,sha256=mHhp0Le-u8PT0Nk3glZrB7qK4JFR906NyB-nHDlnYC8,1039
17
+ velbusaio/vlp_reader.py,sha256=2oSbBOR2bgaSGnL1JP8TJaHgbY20ZgmCfSivtINuiR8,3997
18
18
  velbusaio/messages/__init__.py,sha256=2Zgrk4VBoSJXKYhwS18gxuiE96jDvnpBBjeYI1yHjqs,6072
19
19
  velbusaio/messages/blind_status.py,sha256=q2CQgcU1ZsXMyjR2co8Eegj0LZRDfRqvpCTVvjovNWA,3432
20
20
  velbusaio/messages/bus_active.py,sha256=AB1mEvbMXRuOaC2CQ7hzKSLmbaJnyFqqXrARi-aSdtg,749
@@ -35,9 +35,9 @@ velbusaio/messages/cover_position.py,sha256=_BgQz3DqZGirXDthj7xq72hxr51FYv2tyrdC
35
35
  velbusaio/messages/cover_up.py,sha256=iFPCWuRhIc54oMQAFz5DZsUW23673_nNHoPFJC_HA_c,2561
36
36
  velbusaio/messages/dali_device_settings.py,sha256=KzJ7OFkFRPuCXSOThuplM48J_kH0I3RE82ZRCpmiqYA,4870
37
37
  velbusaio/messages/dali_device_settings_request.py,sha256=InTnrwLD7l9gCHXKlJ69FHMXcejtYMUZBtnffx9nDQU,1476
38
- velbusaio/messages/dali_dim_value_status.py,sha256=Kgtfb65HCku4OBm6uaxKFpK3VtlYaTemSjMhZqngqkY,1059
38
+ velbusaio/messages/dali_dim_value_status.py,sha256=MS3bWgBYUsQ9w7jrRRUI8QoQmziTVCYExfweAezT2E0,1089
39
39
  velbusaio/messages/dimmer_channel_status.py,sha256=v3VszfQoPY05Dt3yzyWIlOJTKSXE1IP044x1x_sLHj4,2501
40
- velbusaio/messages/dimmer_status.py,sha256=pWsWXJGWmYGio4FyUktI9aYnIBQkGCQ5ZwaeqnenYsU,2938
40
+ velbusaio/messages/dimmer_status.py,sha256=AikJN-jfdFy8rXrGMSMfrhFcYE1yHlorffxk-bcKCXU,3067
41
41
  velbusaio/messages/edge_set_color.py,sha256=YhxTv-WtDrZ_O67Ro6V-bcHrxj3tyBI1GuzTI5LkIVw,3306
42
42
  velbusaio/messages/edge_set_custom_color.py,sha256=Lz-7prBveqPwuDWv2Mn13X9TAXGBEquw8xz2_y9NtTk,1338
43
43
  velbusaio/messages/fast_blinking_led.py,sha256=_AaG6NcjzG_cBNARogzu56l7DNpGU11XGrDHIkMuKss,909
@@ -54,7 +54,7 @@ velbusaio/messages/memory_dump_request.py,sha256=xrPsdpygD9DUF3lp-BzJacuq1dNkqz2
54
54
  velbusaio/messages/module_status.py,sha256=Y2OQUzijtA27snTEVHlyoOrcaLQbSq_3KHKAVDLGR_o,6662
55
55
  velbusaio/messages/module_status_request.py,sha256=s3h8F6Dpmjcb4hqDQKIzdOgocW1F_st40cctcW7WmQ4,961
56
56
  velbusaio/messages/module_subtype.py,sha256=KG-OC0eW-ZRnP1P8EWmcay6O-no6Mn2cklEjokaIW9M,1487
57
- velbusaio/messages/module_type.py,sha256=pzIs4VmPRpquV-Z6UJg8dpwXp0TTGTLpoBNuqSY1t1I,4221
57
+ velbusaio/messages/module_type.py,sha256=l5CCLWdsYPxCB4yXrtnJFU9nbKUWTHgIUj-8d2yNRBQ,4373
58
58
  velbusaio/messages/module_type_request.py,sha256=fntMYCF1WVwSseCy3zRoZQH6JeGz8JDfTkwHC3AvcB4,754
59
59
  velbusaio/messages/psu_load.py,sha256=001KIz84CbnC54gHxhfAiNbcEMRpVUBzDwQ7T-yxY8s,1232
60
60
  velbusaio/messages/psu_values.py,sha256=Yd_ndU8yIei7CoL2NV6FHCefO5h_gzRsPNUYxGUGP0o,1300
@@ -121,7 +121,7 @@ velbusaio/module_spec/13.json,sha256=reXGXbaTfVVmLekrXHlfzjsV424bb0ozG7q-PxDv27U
121
121
  velbusaio/module_spec/14.json,sha256=ym2T2jCds-lC7QBZr7mqb0L9PlSmQflwpFnlEDxByjk,161
122
122
  velbusaio/module_spec/15.json,sha256=VDkI3bJRZgpfAsCiEzRJls7Hj7spmaKZ6XR8TjyymAQ,2698
123
123
  velbusaio/module_spec/16.json,sha256=9sdbNwyQQRkGsd477Oyu279tRcSydQC0bGwSQrhnVTU,3459
124
- velbusaio/module_spec/17.json,sha256=WeWeIV6euBGkPq7jBv5rbSykZwhDP6nPzrfk7rrYz-w,3490
124
+ velbusaio/module_spec/17.json,sha256=bLZ7JDt58vZUxPT6JY1xYeWWb1-jHkOhpBRUghjkQSA,3714
125
125
  velbusaio/module_spec/18.json,sha256=ylZ7_86DYCkHxk40ZqsGYEkLeUslZ_-b_ftUihfJ2bQ,3484
126
126
  velbusaio/module_spec/1A.json,sha256=dvIxgufmgIhZhsBuVEa27KmukJyvnFciorqpxwRNWFM,2581
127
127
  velbusaio/module_spec/1B.json,sha256=JbHvdmoRnsqyqhCItsTOP-UK_Yz_ha1Km9CXDe68YVc,3159
@@ -184,7 +184,7 @@ velbusaio/module_spec/5C.json,sha256=yw_XlccuFp-buRL_NXThwQLwblVRa0diLpUDm9Exrg0
184
184
  velbusaio/module_spec/5F.json,sha256=ebSwLMUW5A4LCRGOrF-vwxSVvrWZMNUWz10Xrmz7SwM,1259
185
185
  velbusaio/module_spec/60.json,sha256=Teih3jAybl5AELjY3gFbZrFN0RIMSU1yuvdsYZlMvTo,74
186
186
  velbusaio/module_spec/broadcast.json,sha256=L2Sc9FhZ7NOuu7K5g5jAhvUQGDP6a2CKrg-uWh_v-sA,2250
187
- velbus_aio-2025.4.2.dist-info/METADATA,sha256=WSJ0-GV-TmG0ZqKE5t87kTenc4-PSzEEO-j1kn9QWmw,4562
188
- velbus_aio-2025.4.2.dist-info/WHEEL,sha256=SmOxYU7pzNKBqASvQJ7DjX3XGUF92lrGhMb3R6_iiqI,91
189
- velbus_aio-2025.4.2.dist-info/top_level.txt,sha256=W0-lSOwD23mm8FqaIe9vY20fKicBMIdUVjF-zmfxRnY,15
190
- velbus_aio-2025.4.2.dist-info/RECORD,,
187
+ velbus_aio-2025.8.0.dist-info/METADATA,sha256=xIAwPhzJOI2M9SgDscvXyYKzwdXuR9YShcwDVYfJ3GM,4562
188
+ velbus_aio-2025.8.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
189
+ velbus_aio-2025.8.0.dist-info/top_level.txt,sha256=W0-lSOwD23mm8FqaIe9vY20fKicBMIdUVjF-zmfxRnY,15
190
+ velbus_aio-2025.8.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (79.0.1)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
velbusaio/controller.py CHANGED
@@ -17,7 +17,6 @@ from velbusaio.channels import (
17
17
  Blind,
18
18
  Button,
19
19
  ButtonCounter,
20
- Channel,
21
20
  Dimmer,
22
21
  EdgeLit,
23
22
  LightSensor,
@@ -61,7 +60,7 @@ class Velbus:
61
60
  self._closing = False
62
61
  self._auto_reconnect = True
63
62
 
64
- self._dsn = dsn
63
+ self._destination = dsn
65
64
  self._handler = PacketHandler(self, one_address)
66
65
  self._modules: dict[int, Module] = {}
67
66
  self._submodules: list[int] = []
@@ -146,12 +145,12 @@ class Velbus:
146
145
  """Connect to the bus and load all the data."""
147
146
  await self._handler.read_protocol_data()
148
147
  # connect to the bus
149
- if ":" in self._dsn:
148
+ if ":" in self._destination:
150
149
  # tcp/ip combination
151
- if not re.search(r"^[A-Za-z0-9+.\-]+://", self._dsn):
150
+ if not re.search(r"^[A-Za-z0-9+.\-]+://", self._destination):
152
151
  # if no scheme, then add the tcp://
153
- self._dsn = f"tcp://{self._dsn}"
154
- parts = urlparse(self._dsn)
152
+ self._destination = f"tcp://{self._destination}"
153
+ parts = urlparse(self._destination)
155
154
  if parts.scheme == "tls":
156
155
  ctx = ssl._create_unverified_context()
157
156
  else:
@@ -176,7 +175,7 @@ class Velbus:
176
175
  await serial_asyncio_fast.create_serial_connection(
177
176
  asyncio.get_event_loop(),
178
177
  lambda: self._protocol,
179
- url=self._dsn,
178
+ url=self._destination,
180
179
  baudrate=38400,
181
180
  bytesize=serial.EIGHTBITS,
182
181
  parity=serial.PARITY_NONE,
@@ -190,7 +189,7 @@ class Velbus:
190
189
 
191
190
  async def start(self) -> None:
192
191
  # if auth is required send the auth key
193
- parts = urlparse(self._dsn)
192
+ parts = urlparse(self._destination)
194
193
  if parts.username:
195
194
  await self._protocol.write_auth_key(parts.username)
196
195
  # scan the bus
@@ -277,3 +276,7 @@ class Velbus:
277
276
  await self.send(SetRealtimeClock(wday=lclt[6], hour=lclt[3], min=lclt[4]))
278
277
  await self.send(SetDate(day=lclt[2], mon=lclt[1], year=lclt[0]))
279
278
  await self.send(SetDaylightSaving(ds=not lclt[8]))
279
+
280
+ async def wait_on_all_messages_sent_async(self) -> None:
281
+ """Wait for all messages to be sent."""
282
+ await self._protocol.wait_on_all_messages_sent_async()
velbusaio/handler.py CHANGED
@@ -11,9 +11,9 @@ import json
11
11
  import logging
12
12
  import os
13
13
  import pathlib
14
- import pprint
15
14
  import sys
16
- from typing import TYPE_CHECKING, Awaitable, Callable
15
+ import time
16
+ from typing import TYPE_CHECKING
17
17
 
18
18
  from aiofile import async_open
19
19
 
@@ -23,10 +23,9 @@ from velbusaio.const import (
23
23
  SCAN_MODULEINFO_TIMEOUT_INTERVAL,
24
24
  SCAN_MODULETYPE_TIMEOUT,
25
25
  )
26
- from velbusaio.helpers import h2, keys_exists
27
- from velbusaio.message import Message
28
26
  from velbusaio.messages.module_subtype import ModuleSubTypeMessage
29
27
  from velbusaio.messages.module_type import ModuleType2Message, ModuleTypeMessage
28
+ from velbusaio.module import Module
30
29
  from velbusaio.raw_message import RawMessage
31
30
 
32
31
  if TYPE_CHECKING:
@@ -35,7 +34,7 @@ if TYPE_CHECKING:
35
34
 
36
35
  class PacketHandler:
37
36
  """
38
- The packetHandler class
37
+ The PacketHandler class
39
38
  """
40
39
 
41
40
  def __init__(
@@ -49,9 +48,11 @@ class PacketHandler:
49
48
  self._one_address = one_address
50
49
  self._typeResponseReceived = asyncio.Event()
51
50
  self._scanLock = asyncio.Lock()
51
+ self._fullScanLock = asyncio.Lock()
52
52
  self._modulescan_address = 0
53
53
  self._scan_complete = False
54
54
  self._scan_delay_msec = 0
55
+ self.__scan_found_addresses: dict[int, ModuleTypeMessage | None] | None = None
55
56
 
56
57
  async def read_protocol_data(self):
57
58
  if sys.version_info >= (3, 13):
@@ -85,84 +86,123 @@ class PacketHandler:
85
86
  return False
86
87
 
87
88
  async def scan(self, reload_cache: bool = False) -> None:
88
- if reload_cache:
89
- self._modulescan_address = 0
90
- self._scan_complete = False
91
- # non-blocking check to see if the cache_dir is empty
92
- loop = asyncio.get_running_loop()
93
- if not reload_cache and await loop.run_in_executor(None, self.empty_cache):
94
- self._log.info("No cache yet, so forcing a bus scan")
95
- reload_cache = True
89
+ start_address = 1
90
+ max_address = 254 + 1
91
+ if self._one_address is not None:
92
+ start_address = self._one_address
93
+ max_address = self._one_address + 1
94
+ self._log.info(
95
+ f"Scanning only one address {self._one_address} ({self._one_address:#02x})"
96
+ )
97
+
96
98
  self._log.info("Start module scan")
97
- while self._modulescan_address < 254:
98
- address = 0
99
- module = None
100
- async with self._scanLock:
101
- self._modulescan_address = self._modulescan_address + 1
102
- address = self._modulescan_address
103
- if self._velbus.addr_is_submodule(address):
99
+ async with self._fullScanLock:
100
+ start_time = time.perf_counter()
101
+ self._scan_complete = False
102
+
103
+ self._log.debug("Waiting for Velbus bus to be ready to scan...")
104
+ await self._velbus.wait_on_all_messages_sent_async() # don't start a scan while messages are still in the queue
105
+ self._log.debug("Velbus bus is ready to scan!")
106
+
107
+ self._log.info("Sending scan type requests to all addresses...")
108
+ start_scan_time = time.perf_counter()
109
+ self.__scan_found_addresses = {}
110
+ for address in range(start_address, max_address):
111
+ cfile = pathlib.Path(f"{self._velbus.get_cache_dir()}/{address}.json")
112
+ if reload_cache and os.path.isfile(cfile):
104
113
  self._log.info(
105
- f"Skipping submodule address {address}, already handled"
114
+ f"Reloading cache for address {address} ({address:#02x})"
115
+ )
116
+ os.remove(cfile)
117
+
118
+ self.__scan_found_addresses[address] = None
119
+ async with self._scanLock:
120
+ await self._velbus.sendTypeRequestMessage(address)
121
+
122
+ await self._velbus.wait_on_all_messages_sent_async()
123
+ scan_time = time.perf_counter() - start_scan_time
124
+ self._log.info(
125
+ f"Sent scan type requests to all addresses in {scan_time:.2f}. Going to wait for responses..."
126
+ )
127
+
128
+ await asyncio.sleep(SCAN_MODULETYPE_TIMEOUT / 1000) # wait for responses
129
+
130
+ self._log.info(
131
+ "Waiting for responses done. Going to check for responses..."
132
+ )
133
+ for address in range(start_address, max_address):
134
+ start_module_scan = time.perf_counter()
135
+ module_type_message: ModuleTypeMessage | None = (
136
+ self.__scan_found_addresses[address]
137
+ )
138
+ module: Module | None = None
139
+ if module_type_message is None:
140
+ self._log.debug(
141
+ f"No module found at address {address} ({address:#02x}). Skipping it."
106
142
  )
107
143
  continue
108
- self._log.info(f"Starting handling scan {address}")
109
- module = self._velbus.get_module(address)
110
144
 
111
- if self._one_address is not None and address != int(self._one_address):
112
145
  self._log.info(
113
- f"Skipping address {address} as we requested to only scan one address {self._one_address}"
146
+ f"Found module at address {address} ({address:#02x}): {module_type_message.module_type_name()}"
114
147
  )
115
- continue
116
-
117
- cfile = pathlib.Path(f"{self._velbus.get_cache_dir()}/{address}.json")
118
- # cleanup the old module cache if needed
119
- scanModule = reload_cache
120
- if scanModule and os.path.isfile(cfile):
121
- os.remove(cfile)
122
- elif os.path.isfile(cfile):
123
- scanModule = os.path.isfile(cfile)
124
- if scanModule:
148
+ # cache_file = pathlib.Path(f"{self._velbus.get_cache_dir()}/{address}.json")
149
+ # TODO: check if cached file module type is the same?
150
+ await self._handle_module_type(module_type_message)
151
+ async with self._scanLock:
152
+ module = self._velbus.get_module(address)
153
+
154
+ if module is None:
155
+ self._log.info(
156
+ f"Module at address {address} ({address:#02x}) could not be loaded. Skipping it."
157
+ )
158
+ continue
159
+
125
160
  try:
126
- self._log.info(f"Starting scan {address}")
127
- self._typeResponseReceived.clear()
128
- await self._velbus.sendTypeRequestMessage(address)
161
+ self._log.debug(
162
+ f"Module {module.get_address()} ({module.get_address():#02x}) detected: start loading"
163
+ )
129
164
  await asyncio.wait_for(
130
- self._typeResponseReceived.wait(),
131
- SCAN_MODULETYPE_TIMEOUT / 1000.0,
165
+ module.load(from_cache=True),
166
+ SCAN_MODULEINFO_TIMEOUT_INITIAL / 1000.0,
132
167
  )
133
- async with self._scanLock:
134
- module = self._velbus.get_module(address)
135
- except asyncio.TimeoutError:
168
+ self._scan_delay_msec = module.get_initial_timeout()
169
+ while self._scan_delay_msec > 50 and not await module.is_loaded():
170
+ # self._log.debug(
171
+ # f"\t... waiting {self._scan_delay_msec} is_loaded={await module.is_loaded()}"
172
+ # )
173
+ self._scan_delay_msec = self._scan_delay_msec - 50
174
+ await asyncio.sleep(0.05)
175
+ module_scan_time = time.perf_counter() - start_module_scan
136
176
  self._log.info(
137
- f"Scan module {address} failed: not present or unavailable"
177
+ f"Scan module {address} ({address:#02x}, {module.get_type_name()}) completed in {module_scan_time:.2f}, module loaded={await module.is_loaded()}"
138
178
  )
139
- if module is not None:
140
- try:
141
- self._log.debug(
142
- f"Module {module._address} detected: start loading"
143
- )
144
- await asyncio.wait_for(
145
- module.load(from_cache=True),
146
- SCAN_MODULEINFO_TIMEOUT_INITIAL / 1000.0,
147
- )
148
- self._scan_delay_msec = module.get_initial_timeout()
149
- while (
150
- self._scan_delay_msec > 50 and not await module.is_loaded()
151
- ):
152
- # self._log.debug(
153
- # f"\t... waiting {self._scan_delay_msec} is_loaded={await module.is_loaded()}"
154
- # )
155
- self._scan_delay_msec = self._scan_delay_msec - 50
156
- await asyncio.sleep(0.05)
157
- self._log.info(
158
- f"Scan module {address} completed, module loaded={await module.is_loaded()}"
159
- )
160
- except asyncio.TimeoutError:
161
- self._log.error(
162
- f"Module {address} did not respond to info requests after successful type request"
163
- )
164
- self._scan_complete = True
165
- self._log.info("Module scan completed")
179
+ except asyncio.TimeoutError:
180
+ self._log.error(
181
+ f"Module {address} ({address:#02x}) did not respond to info requests after successful type request"
182
+ )
183
+
184
+ self._scan_complete = True
185
+ total_time = time.perf_counter() - start_time
186
+ self._log.info(f"Module scan completed in {total_time:.2f} seconds")
187
+
188
+ async def __handle_module_type_response_async(self, rawmsg: RawMessage) -> None:
189
+ """
190
+ Handle a received module type response packet
191
+ """
192
+ address = rawmsg.address
193
+
194
+ if self.__scan_found_addresses is None:
195
+ self._log.warning(
196
+ f"Received module type response for address {address} ({address:#02x}) but no scan in progress"
197
+ )
198
+ return
199
+
200
+ tmsg: ModuleTypeMessage = ModuleTypeMessage()
201
+ tmsg.populate(rawmsg.priority, address, rawmsg.rtr, rawmsg.data_only)
202
+ self._log.debug(
203
+ f"A '{tmsg.module_type_name()}' ({tmsg.module_type:#02x}) lives on address {address} ({address:#02x})"
204
+ )
205
+ self.__scan_found_addresses[address] = tmsg
166
206
 
167
207
  async def handle(self, rawmsg: RawMessage) -> None:
168
208
  """
@@ -180,20 +220,8 @@ class PacketHandler:
180
220
  data = rawmsg.data_only
181
221
 
182
222
  # handle module type response message
183
- if command_value == 0xFF and not self._scan_complete:
184
- tmsg: ModuleTypeMessage = ModuleTypeMessage()
185
- tmsg.populate(priority, address, rtr, data)
186
- async with self._scanLock:
187
- await self._handle_module_type(tmsg)
188
- if address == self._modulescan_address:
189
- self._typeResponseReceived.set()
190
- else:
191
- self._log.debug(
192
- f"Unexpected module type message module address {address}, Velbuslink scan?"
193
- )
194
- self._modulescan_address = address - 1
195
-
196
- self._typeResponseReceived.set()
223
+ if command_value == 0xFF:
224
+ await self.__handle_module_type_response_async(rawmsg)
197
225
 
198
226
  # handle module subtype response message
199
227
  elif command_value in (0xB0, 0xA7, 0xA6) and not self._scan_complete:
velbusaio/message.py CHANGED
@@ -4,6 +4,7 @@ The velbus abstract message class
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
+ import enum
7
8
  import json
8
9
 
9
10
  from velbusaio.const import PRIORITY_FIRMWARE, PRIORITY_HIGH, PRIORITY_LOW
@@ -65,8 +66,13 @@ class Message:
65
66
  continue
66
67
  if callable(getattr(self, key)) or key.startswith("__"):
67
68
  del me[key]
68
- if isinstance(me[key], (bytes, bytearray)):
69
+ if isinstance(me[key], (bytes, bytearray, enum.Enum)):
69
70
  me[key] = str(me[key])
71
+ else:
72
+ try:
73
+ json.dumps(me[key]) # Test if the value is JSON serializable
74
+ except (TypeError, ValueError):
75
+ me[key] = str(me[key]) # Convert non-serializable objects to string
70
76
  return me
71
77
 
72
78
  def to_json(self) -> str:
@@ -10,7 +10,7 @@ from velbusaio.message import Message
10
10
  COMMAND_CODE = 0xA5
11
11
 
12
12
 
13
- @register(COMMAND_CODE, ["VMBDALI", "VMBDALI-20"])
13
+ @register(COMMAND_CODE, ["VMBDALI", "VMBDALI-20", "VMB8DC-20", "VMB4LEDPWM-20"])
14
14
  class DimValueStatus(Message):
15
15
  """
16
16
  send by: VMBDALI
@@ -25,7 +25,18 @@ LED_FAST_BLINKING = 1 << 5
25
25
  LED_VERY_FAST_BLINKING = 1 << 4
26
26
 
27
27
 
28
- @register(COMMAND_CODE, ["VMB1DM", "VMBDME", "VMB1LED"])
28
+ @register(
29
+ COMMAND_CODE,
30
+ [
31
+ "VMB1DM",
32
+ "VMBDME",
33
+ "VMB1LED",
34
+ "VMBDALI",
35
+ "VMBDALI-20",
36
+ "VMB8DC-20",
37
+ "VMB4LEDPWM-20",
38
+ ],
39
+ )
29
40
  class DimmerStatusMessage(Message):
30
41
  """
31
42
  sent by: VMBDME
@@ -6,7 +6,7 @@ from __future__ import annotations
6
6
 
7
7
  import struct
8
8
 
9
- from velbusaio.command_registry import register
9
+ from velbusaio.command_registry import MODULE_DIRECTORY, register
10
10
  from velbusaio.message import Message
11
11
 
12
12
  COMMAND_CODE = 0xFF
@@ -110,11 +110,16 @@ class ModuleTypeMessage(Message):
110
110
  self.build_week = 0
111
111
  self.set_defaults(address)
112
112
 
113
- def module_name(self) -> str:
113
+ def module_type_name(self) -> str:
114
114
  """
115
115
  :return: str
116
116
  """
117
- return "Unknown"
117
+
118
+ return (
119
+ MODULE_DIRECTORY[self.module_type]
120
+ if self.module_type in MODULE_DIRECTORY
121
+ else "Unknown"
122
+ )
118
123
 
119
124
  def populate(self, priority, address, rtr, data) -> None:
120
125
  """
velbusaio/module.py CHANGED
@@ -165,6 +165,7 @@ class Module:
165
165
  self._is_loading = False
166
166
  self._channels = {}
167
167
  self.loaded = False
168
+ self._loaded_cache = {}
168
169
 
169
170
  def get_initial_timeout(self) -> int:
170
171
  return SCAN_MODULEINFO_TIMEOUT_INITIAL
@@ -243,6 +244,9 @@ class Module:
243
244
  d["channels"][num] = chan.to_cache()
244
245
  return d
245
246
 
247
+ def get_address(self) -> int:
248
+ return self._address
249
+
246
250
  def get_addresses(self) -> list:
247
251
  """
248
252
  Get all addresses for this module
@@ -550,6 +554,10 @@ class Module:
550
554
  await self._update_channel(
551
555
  message.channel, {"power": message.power, "energy": message.energy}
552
556
  )
557
+ elif isinstance(message, DimValueStatus):
558
+ for offset, dim_value in enumerate(message.dim_values):
559
+ channel = message.channel + offset
560
+ await self._update_channel(channel, {"state": dim_value})
553
561
 
554
562
  async def _update_channel(self, channel: int, updates: dict):
555
563
  try:
@@ -569,12 +577,8 @@ class Module:
569
577
  # start the loading
570
578
  self._is_loading = True
571
579
  # see if we have a cache
572
- try:
573
- cfile = pathlib.Path(f"{self._cache_dir}/{self._address}.json")
574
- async with async_open(cfile, "r") as fl:
575
- cache = json.loads(await fl.read())
576
- except OSError:
577
- cache = {}
580
+ cache = await self._get_cache()
581
+ self._loaded_cache = cache
578
582
  # load default channels
579
583
  await self._load_default_channels()
580
584
 
@@ -589,8 +593,8 @@ class Module:
589
593
  if "channels" in cache:
590
594
  for num, chan in cache["channels"].items():
591
595
  self._channels[int(num)]._name = chan["name"]
592
- if "sub_device" in chan:
593
- self._channels[int(num)]._sub_device = chan["sub_device"]
596
+ if "subdevice" in chan:
597
+ self._channels[int(num)]._sub_device = chan["subdevice"]
594
598
  else:
595
599
  self._channels[int(num)]._sub_device = False
596
600
  if "Unit" in chan:
@@ -604,6 +608,15 @@ class Module:
604
608
  self._is_loading = False
605
609
  await self._request_module_status()
606
610
 
611
+ async def _get_cache(self):
612
+ try:
613
+ cfile = pathlib.Path(f"{self._cache_dir}/{self._address}.json")
614
+ async with async_open(cfile, "r") as fl:
615
+ cache = json.loads(await fl.read())
616
+ except OSError:
617
+ cache = {}
618
+ return cache
619
+
607
620
  def _load(self) -> None:
608
621
  """
609
622
  Method for per module type loading
@@ -857,7 +870,25 @@ class VmbDali(Module):
857
870
  if message.channel in self._channels:
858
871
  del self._channels[message.channel]
859
872
  elif message.data.device_type == DaliDeviceType.LedModule:
860
- if self._channels.get(message.channel).__class__ != Dimmer:
873
+ cache = self._loaded_cache
874
+ if (
875
+ "channels" in cache
876
+ and str(message.channel) in cache["channels"]
877
+ and cache["channels"][str(message.channel)]["type"] == "Dimmer"
878
+ ):
879
+ # If we have a cached dimmer channel, use that name
880
+ name = cache["channels"][str(message.channel)]["name"]
881
+ self._channels[message.channel] = Dimmer(
882
+ self,
883
+ message.channel,
884
+ name,
885
+ False, # set False to enable an already loaded Dimmer
886
+ True,
887
+ self._writer,
888
+ self._address,
889
+ slider_scale=254,
890
+ )
891
+ elif self._channels.get(message.channel).__class__ != Dimmer:
861
892
  # New or changed type, replace channel:
862
893
  self._channels[message.channel] = Dimmer(
863
894
  self,
@@ -914,8 +945,6 @@ class VmbDali(Module):
914
945
  else:
915
946
  return await super().on_message(message)
916
947
 
917
- await self._cache()
918
-
919
948
  async def _request_channel_name(self) -> None:
920
949
  # Channel names are requested after channel scan
921
950
  # don't do them here (at initialization time)
@@ -113,7 +113,17 @@
113
113
  "03FE": { "ModuleName": "62" },
114
114
  "03FF": { "ModuleName": "64:Save" }
115
115
  },
116
- "ModuleName": "03C0-03FF"
116
+ "ModuleName": "03C0-03FF",
117
+ "Channels": {
118
+ "01": "0000-000F",
119
+ "02": "0010-001F",
120
+ "03": "0020-002F",
121
+ "04": "0030-003F",
122
+ "05": "0040-004F",
123
+ "06": "0050-005F",
124
+ "07": "0060-006F",
125
+ "08": "0070-007F"
126
+ }
117
127
  },
118
128
  "Type": "VMB6PBN"
119
129
  }
velbusaio/protocol.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import binascii
5
5
  import logging
6
+ import time
6
7
  import typing as t
7
8
  from asyncio import transports
8
9
 
@@ -180,20 +181,22 @@ class VelbusProtocol(asyncio.BufferedProtocol):
180
181
  await self._write_transport_lock.acquire()
181
182
  while self._restart_writer:
182
183
  # wait for an item from the queue
183
- msg_info = await self._send_queue.get()
184
+ msg_info: RawMessage | None = await self._send_queue.get()
184
185
  if msg_info is None:
185
186
  self._restart_writer = False
186
187
  return
187
188
  message_sent = False
188
189
  try:
190
+ start_time = time.perf_counter()
189
191
  while not message_sent:
190
192
  message_sent = await self._write_message(msg_info)
191
- if msg_info.command == 0xEF:
192
- # 'channel name request' command provokes in worst case 99 answer packets from VMBGPOD
193
- queue_sleep_time = SLEEP_TIME * 33
194
- else:
195
- queue_sleep_time = SLEEP_TIME
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)
196
198
  await asyncio.sleep(queue_sleep_time)
199
+
197
200
  except (asyncio.CancelledError, GeneratorExit) as exc:
198
201
  if not self._closing:
199
202
  self._log.error(f"Stopping Velbus writer due to {exc!r}")
@@ -205,6 +208,22 @@ class VelbusProtocol(asyncio.BufferedProtocol):
205
208
  self._write_transport_lock.release()
206
209
  self._log.debug("Ending Velbus write message from send queue")
207
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
+
208
227
  @backoff.on_predicate(
209
228
  backoff.expo,
210
229
  lambda is_sent: not is_sent,
@@ -218,3 +237,7 @@ class VelbusProtocol(asyncio.BufferedProtocol):
218
237
  return True
219
238
  else:
220
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/vlp_reader.py CHANGED
@@ -1,33 +1,113 @@
1
+ import importlib.resources
2
+ import json
3
+ import logging
4
+ import sys
5
+
6
+ from aiofile import async_open
1
7
  from bs4 import BeautifulSoup
2
8
 
9
+ from velbusaio.command_registry import MODULE_DIRECTORY
10
+ from velbusaio.helpers import h2
11
+
3
12
 
4
13
  class vlpFile:
5
14
 
6
15
  def __init__(self, file_path) -> None:
7
16
  self._file_path = file_path
8
- self._soup = None
9
17
  self._modules = {}
10
- self._read()
11
- self._parse()
12
- print(self._modules)
18
+ self._log = logging.getLogger("velbus-vlpFile")
19
+
20
+ def get(self) -> dict:
21
+ return self._modules
13
22
 
14
- def _read(self) -> None:
23
+ async def read(self) -> None:
15
24
  with open(self._file_path) as file:
16
25
  xml_content = file.read()
17
- self._soup = BeautifulSoup(xml_content, "xml")
18
-
19
- def _parse(self) -> None:
20
- for module in self._soup.find_all("Module"):
21
- self._parse_module(module)
22
-
23
- def _parse_module(self, module) -> None:
24
- mod_info = {}
25
- mod_info["name"] = module.find("Caption").get_text()
26
- mod_info["addresses"] = module["address"]
27
- mod_info["build"] = module["build"]
28
- mod_info["serial"] = module["serial"]
29
- mod_info["build"] = module["build"]
30
- mod_info["type"] = module["type"]
31
- memory = module.find("Memory")
32
- mod_info["memory"] = memory.get_text()
33
- self._modules[mod_info["addresses"]] = mod_info
26
+ _soup = BeautifulSoup(xml_content, "xml")
27
+ for module in _soup.find_all("Module"):
28
+ self._modules[module["address"]] = 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
+ await self._modules[module["address"]].load_module_spec()
37
+ print(self._modules[module["address"]].get_channel_name(1))
38
+ print(self._modules[module["address"]].get_channel_name(2))
39
+ print(self._modules[module["address"]].get_channel_name(3))
40
+ print(self._modules[module["address"]].get_channel_name(4))
41
+ print(self._modules[module["address"]].get_channel_name(5))
42
+ print(self._modules[module["address"]].get_channel_name(10))
43
+
44
+
45
+ class vlpModule:
46
+
47
+ def __init__(self, name, addresses, build, serial, type, memory) -> None:
48
+ self._name = name
49
+ self._addresses = addresses
50
+ self._build = build
51
+ self._serial = serial
52
+ self._type = type
53
+ self._memory = memory
54
+ self._spec = {}
55
+ self._type_id = next(
56
+ (key for key, value in MODULE_DIRECTORY.items() if value == self._type),
57
+ None,
58
+ )
59
+ self._log = logging.getLogger("velbus-vlpFile")
60
+
61
+ def get(self) -> dict:
62
+ return {
63
+ "name": self._name,
64
+ "addresses": self._addresses,
65
+ "build": self._build,
66
+ "serial": self._serial,
67
+ "type": self._type,
68
+ "memory": self._memory,
69
+ }
70
+
71
+ def get_name(self) -> str:
72
+ return self._name
73
+
74
+ def get_channel_name(self, chan: int) -> str | None:
75
+ self._log.debug(f"get_channel_name: {chan}")
76
+ if "Memory" not in self._spec:
77
+ self._log.debug(" => no Memory locations found")
78
+ return None
79
+ if "Channels" not in self._spec["Memory"]:
80
+ self._log.debug(" => no Channels Memory locations found")
81
+ return None
82
+ if h2(chan) not in self._spec["Memory"]["Channels"]:
83
+ self._log.debug(f" => no chan {chan} Memory locations found")
84
+ return None
85
+ byte_data = bytes.fromhex(
86
+ self._read_from_memory(self._spec["Memory"]["Channels"][h2(chan)]).replace(
87
+ "FF", ""
88
+ )
89
+ )
90
+ return byte_data.decode("ascii")
91
+
92
+ async def load_module_spec(self) -> None:
93
+ if sys.version_info >= (3, 13):
94
+ with importlib.resources.path(
95
+ __name__, f"module_spec/{h2(self._type_id)}.json"
96
+ ) as fspath:
97
+ async with async_open(fspath) as protocol_file:
98
+ self._spec = json.loads(await protocol_file.read())
99
+ else:
100
+ async with async_open(
101
+ str(
102
+ importlib.resources.files(__name__.split(".")[0]).joinpath(
103
+ f"module_spec/{h2(self._type)}.json"
104
+ )
105
+ )
106
+ ) as protocol_file:
107
+ self._spec = json.loads(await protocol_file.read())
108
+
109
+ def _read_from_memory(self, address_range) -> str | None:
110
+ start_str, end_str = address_range.split("-")
111
+ start = int(start_str, 16) * 2
112
+ end = (int(end_str, 16) + 1) * 2
113
+ return self._memory[start:end]