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/channels.py CHANGED
@@ -1,25 +1,29 @@
1
1
  """
2
2
  author: Maikel Punie <maikel.punie@gmail.com>
3
3
  """
4
+
4
5
  from __future__ import annotations
5
6
 
6
- import json
7
+ import asyncio
8
+ import math
7
9
  import string
10
+ from typing import TYPE_CHECKING, Any, Awaitable, Callable
8
11
 
9
12
  from velbusaio.command_registry import commandRegistry
10
13
  from velbusaio.const import (
11
14
  DEVICE_CLASS_ILLUMINANCE,
12
15
  DEVICE_CLASS_TEMPERATURE,
13
16
  ENERGY_KILO_WATT_HOUR,
14
- ENERGY_WATT_HOUR,
15
17
  TEMP_CELSIUS,
16
- VOLUME_CUBIC_METER,
17
18
  VOLUME_CUBIC_METER_HOUR,
18
- VOLUME_LITERS,
19
19
  VOLUME_LITERS_HOUR,
20
20
  )
21
- from velbusaio.messages.switch_relay_off import SwitchRelayOffMessage
22
- from velbusaio.messages.switch_relay_on import SwitchRelayOnMessage
21
+ from velbusaio.message import Message
22
+ from velbusaio.messages.edge_set_color import CustomColorPriority, SetEdgeColorMessage
23
+ from velbusaio.messages.module_status import PROGRAM_SELECTION
24
+
25
+ if TYPE_CHECKING:
26
+ from velbusaio.module import Module
23
27
 
24
28
 
25
29
  class Channel:
@@ -28,10 +32,20 @@ class Channel:
28
32
  This is the basic abstract class of a velbus channel
29
33
  """
30
34
 
31
- def __init__(self, module, num, name, nameEditable, writer, address):
35
+ def __init__(
36
+ self,
37
+ module: Module,
38
+ num: int,
39
+ name: str,
40
+ nameEditable: bool,
41
+ subDevice: bool,
42
+ writer: Callable[[Message], Awaitable[None]],
43
+ address: int,
44
+ ):
32
45
  self._num = num
33
46
  self._module = module
34
47
  self._name = name
48
+ self._subDevice = subDevice
35
49
  if not nameEditable:
36
50
  self._is_loaded = True
37
51
  else:
@@ -41,7 +55,7 @@ class Channel:
41
55
  self._on_status_update = []
42
56
  self._name_parts = {}
43
57
 
44
- def get_module_type(self) -> str:
58
+ def get_module_type(self) -> int:
45
59
  return self._module.get_type()
46
60
 
47
61
  def get_module_type_name(self) -> str:
@@ -50,8 +64,16 @@ class Channel:
50
64
  def get_module_serial(self) -> str:
51
65
  return self._module.get_serial()
52
66
 
53
- def get_module_address(self) -> int:
54
- return self._module._address
67
+ def get_module_address(self, chan_type: str = "") -> int:
68
+ """Return (sub)module address for channel"""
69
+ if chan_type == "Button" and self._num > 24:
70
+ return self._module.get_addresses()[3]
71
+ elif chan_type == "Button" and self._num > 16:
72
+ return self._module.get_addresses()[2]
73
+ elif chan_type == "Button" and self._num > 8:
74
+ return self._module.get_addresses()[1]
75
+ else:
76
+ return self._address
55
77
 
56
78
  def get_module_sw_version(self) -> str:
57
79
  return self._module.get_sw_version()
@@ -60,33 +82,48 @@ class Channel:
60
82
  return self._num
61
83
 
62
84
  def get_full_name(self) -> str:
85
+ if self._subDevice:
86
+ return f"{self._module.get_name()} ({self._module.get_type_name()}) - {self._name}"
63
87
  return f"{self._module.get_name()} ({self._module.get_type_name()})"
64
88
 
65
89
  def is_loaded(self) -> bool:
66
90
  """
67
91
  Is this channel loaded
68
-
69
- :return: Boolean
70
92
  """
71
93
  return self._is_loaded
72
94
 
73
95
  def is_counter_channel(self) -> bool:
74
96
  return False
75
97
 
98
+ def is_temperature(self) -> bool:
99
+ return False
100
+
101
+ def is_sub_device(self) -> bool:
102
+ return self._subDevice
103
+
76
104
  def get_name(self) -> str:
77
105
  """
78
106
  :return: the channel name
79
107
  """
80
108
  return self._name
81
109
 
82
- def set_name_part(self, part, name) -> None:
110
+ def set_name_char(self, pos: int, char: int) -> None:
111
+ self._is_loaded = True
112
+ self._name_parts = {}
113
+ # make sure the string is long enough
114
+ while len(self._name) < int(pos):
115
+ self._name += " "
116
+ # store the char on correct pos
117
+ self._name = self._name[: int(pos)] + chr(char) + self._name[int(pos) + 1 :]
118
+
119
+ def set_name_part(self, part: int, name: str) -> None:
83
120
  """
84
121
  Set a part of the channel name
85
122
  """
86
123
  # if int(part) not in self._name_parts:
87
124
  # return
88
125
  self._name_parts[int(part)] = name
89
- if int(part) == 3:
126
+ if len(self._name_parts) == 3:
90
127
  self._generate_name()
91
128
 
92
129
  def _generate_name(self) -> None:
@@ -96,76 +133,137 @@ class Channel:
96
133
  name = self._name_parts[1] + self._name_parts[2] + self._name_parts[3]
97
134
  self._name = "".join(filter(lambda x: x in string.printable, name))
98
135
  self._is_loaded = True
99
- self._name_parts = None
136
+ self._name_parts = {}
100
137
 
101
138
  def __getstate__(self):
102
139
  d = self.__dict__
103
- return {k: d[k] for k in d if k != "_writer" and k != "_on_status_update"}
140
+ return {
141
+ k: d[k]
142
+ for k in d
143
+ if k != "_writer" and k != "_on_status_update" and k != "_name_parts"
144
+ }
145
+
146
+ def to_cache(self) -> dict:
147
+ dst = {
148
+ "name": self._name,
149
+ "type": type(self).__name__,
150
+ "subdevice": self._subDevice,
151
+ }
152
+ if hasattr(self, "_Unit"):
153
+ dst["Unit"] = self._Unit
154
+ return dst
104
155
 
105
156
  def __setstate__(self, state):
106
157
  self.__dict__.update(state)
107
158
  self._on_status_update = []
159
+ self._name_parts = {}
108
160
 
109
- def __repr__(self):
161
+ def __repr__(self) -> str:
110
162
  items = []
111
163
  for k, v in self.__dict__.items():
112
- if k not in ["_module", "_writer", "_name_parts"]:
164
+ if k not in ["_module", "_writer", "_name_parts", "_class"]:
113
165
  items.append(f"{k} = {v!r}")
114
166
  return "{}[{}]".format(type(self), ", ".join(items))
115
167
 
116
- def __str__(self):
168
+ def __str__(self) -> str:
117
169
  return self.__repr__()
118
170
 
171
+ def get_channel_info(self) -> dict[str, Any]:
172
+ data = {}
173
+ for key, value in self.__dict__.items():
174
+ data["type"] = self.__class__.__name__
175
+ if key not in ["_module", "_writer", "_name_parts", "_on_status_update"]:
176
+ data[key.replace("_", "", 1)] = value
177
+ return data
178
+
119
179
  async def update(self, data: dict) -> None:
120
180
  """
121
181
  Set the attributes of this channel
122
182
  """
123
- for key, val in data.items():
124
- setattr(self, f"_{key}", val)
125
- for m in self._on_status_update:
126
- await m()
183
+ for key, new_val in data.items():
184
+ cur_val = getattr(self, f"_{key}", None)
185
+ if cur_val is None or cur_val != new_val:
186
+ setattr(self, f"_{key}", new_val)
187
+ for m in self._on_status_update:
188
+ await m()
127
189
 
128
- def get_categories(self) -> list:
190
+ def get_categories(self) -> list[str]:
129
191
  """
130
- Get the categories (for hass)
192
+ Get the categories (mainly for home-assistant)
131
193
  """
132
194
  # COMPONENT_TYPES = ["switch", "sensor", "binary_sensor", "cover", "climate", "light"]
133
195
  return []
134
196
 
135
- def on_status_update(self, meth: type) -> None:
197
+ def on_status_update(self, meth: Callable[[], Awaitable[None]]) -> None:
136
198
  self._on_status_update.append(meth)
137
199
 
200
+ def remove_on_status_update(self, meth: Callable[[], Awaitable[None]]) -> None:
201
+ self._on_status_update.remove(meth)
202
+
203
+ def get_counter_state(self) -> int:
204
+ raise NotImplementedError()
205
+
206
+ def get_counter_unit(self) -> str:
207
+ raise NotImplementedError()
208
+
209
+ def get_max(self) -> int:
210
+ raise NotImplementedError()
211
+
212
+ def get_min(self) -> int:
213
+ raise NotImplementedError()
214
+
215
+ def is_water(self) -> bool:
216
+ return False
217
+
218
+ async def press(self) -> None:
219
+ raise NotImplementedError()
220
+
138
221
 
139
222
  class Blind(Channel):
140
223
  """
141
224
  A blind channel
142
- HASS OK
143
225
  """
144
226
 
145
227
  _state = None
228
+ # State reports the direction of *movement*: moving up, moving down or stopped
146
229
  _position = None
230
+ # Position reporting is not supported by VMBxBL modules (only in BLE/BLS)
147
231
 
148
- def get_categories(self) -> list:
232
+ def get_categories(self) -> list[str]:
149
233
  return ["cover"]
150
234
 
151
- def get_position(self) -> str:
235
+ def get_position(self) -> int | None:
152
236
  return self._position
153
237
 
154
238
  def get_state(self) -> str:
155
239
  return self._state
156
240
 
157
- def is_closed(self) -> bool:
158
- if self._state == 0x02:
159
- return True
160
- return False
241
+ def is_opening(self) -> bool:
242
+ return self._state == 0x01
161
243
 
162
- def is_open(self) -> bool:
163
- if self._state == 0x01:
164
- return True
165
- return False
244
+ def is_closing(self) -> bool:
245
+ return self._state == 0x02
246
+
247
+ def is_stopped(self) -> bool:
248
+ return self._state == 0x00
249
+
250
+ def is_closed(self) -> bool | None:
251
+ """Report if the blind is fully closed."""
252
+ if self._position is None:
253
+ return None
254
+ # else:
255
+ return self._position == 100
256
+
257
+ def is_open(self) -> bool | None:
258
+ """Report if the blind is fully open."""
259
+ if self._position is None:
260
+ return None
261
+ return self._position == 0
166
262
 
167
263
  def support_position(self) -> bool:
168
- return False
264
+ # position will be populated after the first BlindStatusNgMessage (during module load)
265
+ # For VMBxBL modules, position will remain None and not be overwritten
266
+ return self._position is not None
169
267
 
170
268
  async def open(self) -> None:
171
269
  cls = commandRegistry.get_command(0x05, self._module.get_type())
@@ -186,6 +284,11 @@ class Blind(Channel):
186
284
  await self._writer(msg)
187
285
 
188
286
  async def set_position(self, position: int) -> None:
287
+ # may not be supported by the module
288
+ if position == 100:
289
+ # at least VMB1BLS ignores command 0x1C with position 0x64
290
+ await self.close()
291
+ return
189
292
  cls = commandRegistry.get_command(0x1C, self._module.get_type())
190
293
  msg = cls(self._address)
191
294
  msg.channel = self._num
@@ -196,15 +299,17 @@ class Blind(Channel):
196
299
  class Button(Channel):
197
300
  """
198
301
  A Button channel
199
- HASS OK
200
302
  """
201
303
 
202
304
  _enabled = True
203
305
  _closed = False
204
306
  _led_state = None
307
+ _long = False
205
308
 
206
- def get_categories(self) -> list:
207
- return ["binary_sensor", "led"]
309
+ def get_categories(self) -> list[str]:
310
+ if self._enabled:
311
+ return ["binary_sensor", "led", "button"]
312
+ return []
208
313
 
209
314
  def is_closed(self) -> bool:
210
315
  """
@@ -223,8 +328,6 @@ class Button(Channel):
223
328
  async def set_led_state(self, state: str) -> None:
224
329
  """
225
330
  Set led
226
-
227
- :return: None
228
331
  """
229
332
  if state == "on":
230
333
  code = 0xF6
@@ -236,29 +339,53 @@ class Button(Channel):
236
339
  code = 0xF5
237
340
  else:
238
341
  return
342
+
343
+ _mod_add = self.get_module_address("Button")
344
+ _chn_num = self._num - self._module.calc_channel_offset(_mod_add)
239
345
  cls = commandRegistry.get_command(code, self._module.get_type())
240
- msg = cls(self._address)
241
- msg.leds = [self._num]
346
+ msg = cls(_mod_add)
347
+ msg.leds = [_chn_num]
242
348
  await self._writer(msg)
243
349
  await self.update({"led_state": state})
244
350
 
351
+ async def press(self) -> None:
352
+ """
353
+ Press the button
354
+ """
355
+ _mod_add = self.get_module_address("Button")
356
+ _chn_num = self._num - self._module.calc_channel_offset(_mod_add)
357
+ # send the just pressed
358
+ cls = commandRegistry.get_command(0x00, self._module.get_type())
359
+ msg = cls(_mod_add)
360
+ msg.closed = [_chn_num]
361
+ await self._writer(msg)
362
+ # wait
363
+ await asyncio.sleep(0.3)
364
+ # send the just released
365
+ msg = cls(_mod_add)
366
+ msg.opened = [_chn_num]
367
+ await self._writer(msg)
368
+
245
369
 
246
370
  class ButtonCounter(Button):
247
371
  """
248
372
  A ButtonCounter channel
249
373
  This channel can act as a button and as a counter
250
- HASS OK
374
+ => standard this is the calculated value
375
+ => is_counter this is the numeric value
251
376
  """
252
377
 
253
378
  _Unit = None
254
379
  _pulses = None
255
380
  _counter = None
256
381
  _delay = None
382
+ _power = None
383
+ _energy = None
257
384
 
258
- def get_categories(self) -> list:
385
+ def get_categories(self) -> list[str]:
259
386
  if self._counter:
260
387
  return ["sensor"]
261
- return ["binary_sensor"]
388
+ return ["binary_sensor", "button"]
262
389
 
263
390
  def is_counter_channel(self) -> bool:
264
391
  if self._counter:
@@ -266,11 +393,13 @@ class ButtonCounter(Button):
266
393
  return False
267
394
 
268
395
  def get_state(self) -> int:
269
- val = 0
396
+ if self._energy:
397
+ return self._energy
270
398
  # if we don't know the delay
271
399
  # or we don't know the unit
272
- # or the daly is the max value
400
+ # or the delay is the max value
273
401
  # we always return 0
402
+ val = 0
274
403
  if not self._delay or not self._Unit or self._delay == 0xFFFF:
275
404
  return round(0, 2)
276
405
  if self._Unit == VOLUME_LITERS_HOUR:
@@ -283,42 +412,74 @@ class ButtonCounter(Button):
283
412
  val = 0
284
413
  return round(val, 2)
285
414
 
286
- def get_unit(self) -> str:
287
- return "W"
415
+ def get_unit(self) -> str | None:
416
+ if self._Unit == VOLUME_LITERS_HOUR:
417
+ return "L"
418
+ if self._Unit == VOLUME_CUBIC_METER_HOUR:
419
+ return "m3"
420
+ if self._Unit == ENERGY_KILO_WATT_HOUR:
421
+ return "W"
422
+ return None
288
423
 
289
424
  def get_counter_state(self) -> int:
425
+ if self._power:
426
+ return self._power
290
427
  return round((self._counter / self._pulses), 2)
291
428
 
292
429
  def get_counter_unit(self) -> str:
293
- return ENERGY_KILO_WATT_HOUR
430
+ return self._Unit
431
+
432
+ def is_water(self) -> bool:
433
+ if self._counter and self._Unit == VOLUME_LITERS_HOUR:
434
+ return True
435
+ return False
294
436
 
295
437
 
296
438
  class Sensor(Button):
297
439
  """
298
440
  A Sensor channel
299
- HASS OK
300
- This is a bit wierd, but this happens because of code sharing with openhab
441
+ This is a bit weird, but this happens because of code sharing with openhab
301
442
  A sensor in this case is actually a Button
302
443
  """
303
444
 
445
+ def get_categories(self) -> list[str]:
446
+ if self._enabled:
447
+ return ["binary_sensor", "led"]
448
+ return []
449
+
304
450
 
305
451
  class ThermostatChannel(Button):
306
452
  """
307
453
  A Thermostat channel
308
454
  These are the booster/heater/alarms
309
- HASS OK
310
455
  """
311
456
 
312
457
 
313
458
  class Dimmer(Channel):
314
459
  """
315
460
  A Dimmer channel
316
- HASS OK
317
461
  """
318
462
 
319
463
  _state: int = 0
320
464
 
321
- def get_categories(self) -> list:
465
+ def __init__(
466
+ self,
467
+ module: Module,
468
+ num: int,
469
+ name: str,
470
+ nameEditable: bool,
471
+ subDevice: bool,
472
+ writer: Callable[[Message], Awaitable[None]],
473
+ address: int,
474
+ slider_scale: int = 100,
475
+ ):
476
+ super().__init__(module, num, name, nameEditable, subDevice, writer, address)
477
+
478
+ self.slider_scale = slider_scale
479
+ # VMB4DC has dim values 0(off), 1-99(dimmed), 100(full on)
480
+ # VMBDALI has dim values 0(off), 1-253(dimmed), 254(full on), 255(previous value)
481
+
482
+ def get_categories(self) -> list[str]:
322
483
  return ["light"]
323
484
 
324
485
  def is_on(self) -> bool:
@@ -333,20 +494,20 @@ class Dimmer(Channel):
333
494
  """
334
495
  Return the dimmer state
335
496
  """
336
- return self._state
497
+ return int(self._state * 100 / self.slider_scale)
337
498
 
338
- async def set_dimmer_state(self, slider, transitiontime=0) -> None:
499
+ async def set_dimmer_state(self, slider: int, transitiontime: int = 0) -> None:
339
500
  """
340
501
  Set dimmer to slider
341
502
  """
342
503
  cls = commandRegistry.get_command(0x07, self._module.get_type())
343
504
  msg = cls(self._address)
344
- msg.dimmer_state = slider
505
+ msg.dimmer_state = int(slider * self.slider_scale / 100)
345
506
  msg.dimmer_transitiontime = int(transitiontime)
346
507
  msg.dimmer_channels = [self._num]
347
508
  await self._writer(msg)
348
509
 
349
- async def restore_dimmer_state(self, transitiontime=0) -> None:
510
+ async def restore_dimmer_state(self, transitiontime: int = 0) -> None:
350
511
  """
351
512
  restore dimmer to last known state
352
513
  """
@@ -360,14 +521,22 @@ class Dimmer(Channel):
360
521
  class Temperature(Channel):
361
522
  """
362
523
  A Temperature sensor channel
363
- HASS OK
364
524
  """
365
525
 
366
526
  _cur = 0
527
+ _cur_precision = None
367
528
  _max = None
368
529
  _min = None
369
-
370
- def get_categories(self) -> list:
530
+ _target = 0
531
+ _cmode = None
532
+ _cool_mode = None
533
+ _cstatus = None
534
+ _thermostat = False
535
+ _sleep_timer = 0
536
+
537
+ def get_categories(self) -> list[str]:
538
+ if self._thermostat:
539
+ return ["sensor", "climate"]
371
540
  return ["sensor"]
372
541
 
373
542
  def get_class(self) -> str:
@@ -379,59 +548,169 @@ class Temperature(Channel):
379
548
  def get_state(self) -> int:
380
549
  return round(self._cur, 2)
381
550
 
551
+ def is_temperature(self) -> bool:
552
+ return True
553
+
554
+ def get_max(self) -> int | None:
555
+ if self._max is None:
556
+ return None
557
+ return round(self._max, 2)
558
+
559
+ def get_min(self) -> int | None:
560
+ if self._min is None:
561
+ return None
562
+ return round(self._min, 2)
563
+
564
+ def get_climate_target(self) -> int:
565
+ return round(self._target, 2)
566
+
567
+ def get_climate_preset(self) -> str:
568
+ return self._cmode
569
+
570
+ def get_climate_mode(self) -> str:
571
+ return self._cstatus
572
+
573
+ def get_cool_mode(self) -> str:
574
+ return self._cool_mode
575
+
576
+ async def set_temp(self, temp: float) -> None:
577
+ cls = commandRegistry.get_command(0xE4, self._module.get_type())
578
+ msg = cls(self._address)
579
+ msg.temp = temp * 2 # TODO: int()
580
+ await self._writer(msg)
581
+
582
+ async def _switch_mode(self) -> None:
583
+ if self._cmode == "safe":
584
+ code = 0xDE
585
+ elif self._cmode == "comfort":
586
+ code = 0xDB
587
+ elif self._cmode == "day":
588
+ code = 0xDC
589
+ else: # "night"
590
+ code = 0xDD
591
+
592
+ if self._cstatus == "run":
593
+ sleep = 0x0
594
+ elif self._cstatus == "manual":
595
+ sleep = 0xFFFF
596
+ elif self._cstatus == "sleep":
597
+ sleep = self._sleep_timer
598
+ else:
599
+ sleep = 0x0
600
+ cls = commandRegistry.get_command(code, self._module.get_type())
601
+ msg = cls(self._address, sleep)
602
+ await self._writer(msg)
603
+
604
+ async def set_preset(self, preset: str) -> None:
605
+ self._cmode = preset
606
+ await self._switch_mode()
607
+
608
+ async def set_climate_mode(self, mode: str) -> None:
609
+ self._cstatus = mode
610
+ await self._switch_mode()
611
+
612
+ async def set_mode(self, mode: str) -> None:
613
+ # TODO: change function name, proposal = set_heat_cool_mode
614
+ if mode == "heat":
615
+ code = 0xE0
616
+ elif mode == "cool":
617
+ code = 0xDF
618
+ # TODO: else case
619
+ cls = commandRegistry.get_command(code, self._module.get_type())
620
+ msg = cls(self._address)
621
+ await self._writer(msg)
622
+
623
+ async def maybe_update_temperature(self, new_temp: float, precision: float) -> None:
624
+ # Based on experiments, Velbus modules seem to truncate (i.e. round down)
625
+ current_temp_rounded_to_precision = (
626
+ math.floor(self._cur / precision) * precision
627
+ )
628
+
629
+ if current_temp_rounded_to_precision == new_temp:
630
+ # The newly received temperature is still in line with our current value,
631
+ # but with reduced precision.
632
+ # Don't update (would lose high precision)
633
+ return
634
+
635
+ elif (
636
+ current_temp_rounded_to_precision - precision
637
+ <= new_temp
638
+ < current_temp_rounded_to_precision
639
+ and self._cur_precision < precision
640
+ ):
641
+ # The newly received temperature is 1 LSb below the current value
642
+ # and the current value was set by a better precision message
643
+ # Modify the received temperature by "adding precision", while still keeping the same low precision value
644
+ # e.g. (decimal digits represent precision)
645
+ # | Actual | Msg | Stored |
646
+ # | 21.0000 | 21.0000 | 21.0000 |
647
+ # | 20.9375 | 20.5 | 20.9375 |
648
+ new_temp = current_temp_rounded_to_precision - self._cur_precision
649
+
650
+ await self.update(
651
+ {
652
+ "cur": new_temp,
653
+ "cur_precision": precision,
654
+ }
655
+ )
656
+
382
657
 
383
658
  class SensorNumber(Channel):
384
659
  """
385
660
  A Numeric Sensor channel
386
- HASS OK
387
661
  """
388
662
 
389
663
  _cur = 0
664
+ _unit = None
390
665
 
391
- def get_categories(self):
666
+ def get_categories(self) -> list[str]:
392
667
  return ["sensor"]
393
668
 
394
- def get_class(self):
669
+ def get_class(self) -> None:
395
670
  return None
396
671
 
397
- def get_unit(self):
398
- return None
672
+ def get_unit(self) -> None:
673
+ return self._unit
399
674
 
400
- def get_state(self):
675
+ def get_state(self) -> float:
401
676
  return round(self._cur, 2)
402
677
 
403
678
 
404
679
  class LightSensor(Channel):
405
680
  """
406
681
  A light sensor channel
407
- HASS OK
408
682
  """
409
683
 
410
684
  _cur = 0
411
685
 
412
- def get_categories(self):
686
+ def get_categories(self) -> list[str]:
413
687
  return ["sensor"]
414
688
 
415
- def get_class(self):
689
+ def get_class(self) -> str:
416
690
  return DEVICE_CLASS_ILLUMINANCE
417
691
 
418
- def get_unit(self):
692
+ def get_unit(self) -> None:
419
693
  return None
420
694
 
421
- def get_state(self):
695
+ def get_state(self) -> float:
422
696
  return round(self._cur, 2)
423
697
 
424
698
 
425
699
  class Relay(Channel):
426
700
  """
427
701
  A Relay channel
428
- HASS OK
429
702
  """
430
703
 
431
704
  _on = None
705
+ _enabled = True
706
+ _inhibit = False
707
+ _forced_on = False
708
+ _disabled = False
432
709
 
433
- def get_categories(self) -> list:
434
- return ["switch"]
710
+ def get_categories(self) -> list[str]:
711
+ if self._enabled:
712
+ return ["switch"]
713
+ return []
435
714
 
436
715
  def is_on(self) -> bool:
437
716
  """
@@ -439,6 +718,15 @@ class Relay(Channel):
439
718
  """
440
719
  return self._on
441
720
 
721
+ def is_inhibit(self) -> bool:
722
+ return self._inhibit
723
+
724
+ def is_forced_on(self) -> bool:
725
+ return self._forced_on
726
+
727
+ def is_disabled(self) -> bool:
728
+ return self._disabled
729
+
442
730
  async def turn_on(self) -> None:
443
731
  """
444
732
  Send the turn on message
@@ -463,8 +751,42 @@ class EdgeLit(Channel):
463
751
  An EdgeLit channel
464
752
  """
465
753
 
466
- # def get_categories(self):
467
- # return ["light"]
754
+ async def reset_color(self, left=True, top=True, right=True, bottom=True):
755
+ msg = SetEdgeColorMessage(self._address)
756
+ msg.apply_background_color = True
757
+ msg.color_idx = 0
758
+ msg.apply_to_left_edge = left
759
+ msg.apply_to_top_edge = top
760
+ msg.apply_to_right_edge = right
761
+ msg.apply_to_bottom_edge = bottom
762
+ msg.apply_to_all_pages = True
763
+ await self._writer(msg)
764
+
765
+ async def set_color(
766
+ self,
767
+ color_idx: int,
768
+ left=True,
769
+ top=True,
770
+ right=True,
771
+ bottom=True,
772
+ blinking=False,
773
+ priority=CustomColorPriority.LOW_PRIORITY,
774
+ ) -> None:
775
+ """
776
+ Send the turn off message
777
+ """
778
+
779
+ msg = SetEdgeColorMessage(self._address)
780
+ msg.apply_background_color = True
781
+ msg.background_blinking = blinking
782
+ msg.color_idx = color_idx
783
+ msg.apply_to_left_edge = left
784
+ msg.apply_to_top_edge = top
785
+ msg.apply_to_right_edge = right
786
+ msg.apply_to_bottom_edge = bottom
787
+ msg.apply_to_all_pages = True
788
+ msg.custom_color_priority = priority
789
+ await self._writer(msg)
468
790
 
469
791
 
470
792
  class Memo(Channel):
@@ -472,32 +794,44 @@ class Memo(Channel):
472
794
  A Memo text
473
795
  """
474
796
 
797
+ async def set(self, txt: str) -> None:
798
+ cls = commandRegistry.get_command(0xAC, self._module.get_type())
799
+ msg = cls(self._address)
800
+ msgcntr = 0
801
+ for char in txt:
802
+ msg.memo_text += char
803
+ if len(msg.memo_text) >= 5:
804
+ msgcntr += 5
805
+ await self._writer(msg)
806
+ msg = cls(self._address)
807
+ msg.start = msgcntr
808
+ await self._writer(msg)
809
+
810
+
811
+ class SelectedProgram(Channel):
812
+ """
813
+ A selected program channel
814
+ """
815
+
816
+ _selected_program_str = None
475
817
 
476
- # _mode = None
477
- # _target = None
478
- # _cur = None
479
- #
480
- # def get_categories(self):
481
- # return ["climate"]
482
- #
483
- # async def set_temp(self, temp) -> None:
484
- # cls = commandRegistry.get_command(0xE4, self._module.get_type())
485
- # msg = cls(self._address)
486
- # msg.temp = temp * 2
487
- # await self._writer(msg)
488
- #
489
- # async def set_mode(self, mode) -> None:
490
- # if mode == "safe":
491
- # code = 0xDE
492
- # elif mode == "comfort":
493
- # code = 0xDB
494
- # elif mode == "day":
495
- # code = 0xDC
496
- # elif mode == "night":
497
- # code = 0xDD
498
- # cls = commandRegistry.get_command(code, self._module.get_type())
499
- # msg = cls(self._address)
500
- # await self._writer(msg)
501
- #
502
- # def get_state(self) -> int:
503
- # return round(self._cur, 2)
818
+ def get_categories(self) -> list[str]:
819
+ return ["select"]
820
+
821
+ def get_class(self) -> None:
822
+ return None
823
+
824
+ def get_options(self) -> list:
825
+ return list(PROGRAM_SELECTION.values())
826
+
827
+ def get_selected_program(self) -> str:
828
+ return self._selected_program_str
829
+
830
+ async def set_selected_program(self, program_str: str) -> None:
831
+ self._selected_program_str = program_str
832
+ command_code = 0xB3
833
+ cls = commandRegistry.get_command(command_code, self._module.get_type())
834
+ index = list(PROGRAM_SELECTION.values()).index(program_str)
835
+ program = list(PROGRAM_SELECTION.keys())[index]
836
+ msg = cls(self._address, program)
837
+ await self._writer(msg)