conson-xp 1.18.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 (176) hide show
  1. conson_xp-1.18.0.dist-info/METADATA +412 -0
  2. conson_xp-1.18.0.dist-info/RECORD +176 -0
  3. conson_xp-1.18.0.dist-info/WHEEL +4 -0
  4. conson_xp-1.18.0.dist-info/entry_points.txt +5 -0
  5. conson_xp-1.18.0.dist-info/licenses/LICENSE +29 -0
  6. xp/__init__.py +9 -0
  7. xp/cli/__init__.py +5 -0
  8. xp/cli/__main__.py +6 -0
  9. xp/cli/commands/__init__.py +153 -0
  10. xp/cli/commands/conbus/__init__.py +25 -0
  11. xp/cli/commands/conbus/conbus.py +128 -0
  12. xp/cli/commands/conbus/conbus_actiontable_commands.py +233 -0
  13. xp/cli/commands/conbus/conbus_autoreport_commands.py +108 -0
  14. xp/cli/commands/conbus/conbus_blink_commands.py +163 -0
  15. xp/cli/commands/conbus/conbus_config_commands.py +29 -0
  16. xp/cli/commands/conbus/conbus_custom_commands.py +57 -0
  17. xp/cli/commands/conbus/conbus_datapoint_commands.py +113 -0
  18. xp/cli/commands/conbus/conbus_discover_commands.py +61 -0
  19. xp/cli/commands/conbus/conbus_event_commands.py +81 -0
  20. xp/cli/commands/conbus/conbus_lightlevel_commands.py +207 -0
  21. xp/cli/commands/conbus/conbus_linknumber_commands.py +102 -0
  22. xp/cli/commands/conbus/conbus_modulenumber_commands.py +104 -0
  23. xp/cli/commands/conbus/conbus_msactiontable_commands.py +94 -0
  24. xp/cli/commands/conbus/conbus_output_commands.py +163 -0
  25. xp/cli/commands/conbus/conbus_raw_commands.py +62 -0
  26. xp/cli/commands/conbus/conbus_receive_commands.py +59 -0
  27. xp/cli/commands/conbus/conbus_scan_commands.py +58 -0
  28. xp/cli/commands/file_commands.py +186 -0
  29. xp/cli/commands/homekit/__init__.py +3 -0
  30. xp/cli/commands/homekit/homekit.py +118 -0
  31. xp/cli/commands/homekit/homekit_start_commands.py +43 -0
  32. xp/cli/commands/module_commands.py +187 -0
  33. xp/cli/commands/reverse_proxy_commands.py +178 -0
  34. xp/cli/commands/server/__init__.py +3 -0
  35. xp/cli/commands/server/server_commands.py +135 -0
  36. xp/cli/commands/telegram/__init__.py +5 -0
  37. xp/cli/commands/telegram/telegram.py +41 -0
  38. xp/cli/commands/telegram/telegram_blink_commands.py +79 -0
  39. xp/cli/commands/telegram/telegram_checksum_commands.py +112 -0
  40. xp/cli/commands/telegram/telegram_discover_commands.py +41 -0
  41. xp/cli/commands/telegram/telegram_linknumber_commands.py +86 -0
  42. xp/cli/commands/telegram/telegram_parse_commands.py +75 -0
  43. xp/cli/commands/telegram/telegram_version_commands.py +52 -0
  44. xp/cli/main.py +87 -0
  45. xp/cli/utils/__init__.py +1 -0
  46. xp/cli/utils/click_tree.py +57 -0
  47. xp/cli/utils/datapoint_type_choice.py +57 -0
  48. xp/cli/utils/decorators.py +351 -0
  49. xp/cli/utils/error_handlers.py +201 -0
  50. xp/cli/utils/formatters.py +312 -0
  51. xp/cli/utils/module_type_choice.py +56 -0
  52. xp/cli/utils/serial_number_type.py +52 -0
  53. xp/cli/utils/system_function_choice.py +57 -0
  54. xp/cli/utils/xp_module_type.py +53 -0
  55. xp/connection/__init__.py +13 -0
  56. xp/connection/exceptions.py +22 -0
  57. xp/models/__init__.py +36 -0
  58. xp/models/actiontable/__init__.py +1 -0
  59. xp/models/actiontable/actiontable.py +43 -0
  60. xp/models/actiontable/msactiontable_xp20.py +53 -0
  61. xp/models/actiontable/msactiontable_xp24.py +58 -0
  62. xp/models/actiontable/msactiontable_xp33.py +65 -0
  63. xp/models/conbus/__init__.py +1 -0
  64. xp/models/conbus/conbus.py +87 -0
  65. xp/models/conbus/conbus_autoreport.py +67 -0
  66. xp/models/conbus/conbus_blink.py +80 -0
  67. xp/models/conbus/conbus_client_config.py +55 -0
  68. xp/models/conbus/conbus_connection_status.py +40 -0
  69. xp/models/conbus/conbus_custom.py +58 -0
  70. xp/models/conbus/conbus_datapoint.py +89 -0
  71. xp/models/conbus/conbus_discover.py +64 -0
  72. xp/models/conbus/conbus_event_raw.py +47 -0
  73. xp/models/conbus/conbus_lightlevel.py +52 -0
  74. xp/models/conbus/conbus_linknumber.py +54 -0
  75. xp/models/conbus/conbus_output.py +57 -0
  76. xp/models/conbus/conbus_raw.py +45 -0
  77. xp/models/conbus/conbus_receive.py +42 -0
  78. xp/models/conbus/conbus_writeconfig.py +60 -0
  79. xp/models/homekit/__init__.py +1 -0
  80. xp/models/homekit/homekit_accessory.py +35 -0
  81. xp/models/homekit/homekit_config.py +106 -0
  82. xp/models/homekit/homekit_conson_config.py +86 -0
  83. xp/models/log_entry.py +130 -0
  84. xp/models/protocol/__init__.py +1 -0
  85. xp/models/protocol/conbus_protocol.py +312 -0
  86. xp/models/response.py +42 -0
  87. xp/models/telegram/__init__.py +1 -0
  88. xp/models/telegram/action_type.py +31 -0
  89. xp/models/telegram/datapoint_type.py +82 -0
  90. xp/models/telegram/event_telegram.py +140 -0
  91. xp/models/telegram/event_type.py +15 -0
  92. xp/models/telegram/input_action_type.py +69 -0
  93. xp/models/telegram/input_type.py +17 -0
  94. xp/models/telegram/module_type.py +188 -0
  95. xp/models/telegram/module_type_code.py +205 -0
  96. xp/models/telegram/output_telegram.py +103 -0
  97. xp/models/telegram/reply_telegram.py +297 -0
  98. xp/models/telegram/system_function.py +116 -0
  99. xp/models/telegram/system_telegram.py +94 -0
  100. xp/models/telegram/telegram.py +28 -0
  101. xp/models/telegram/telegram_type.py +19 -0
  102. xp/models/telegram/timeparam_type.py +51 -0
  103. xp/models/write_config_type.py +33 -0
  104. xp/services/__init__.py +26 -0
  105. xp/services/actiontable/__init__.py +1 -0
  106. xp/services/actiontable/actiontable_serializer.py +273 -0
  107. xp/services/actiontable/msactiontable_serializer.py +7 -0
  108. xp/services/actiontable/msactiontable_xp20_serializer.py +169 -0
  109. xp/services/actiontable/msactiontable_xp24_serializer.py +120 -0
  110. xp/services/actiontable/msactiontable_xp33_serializer.py +239 -0
  111. xp/services/conbus/__init__.py +1 -0
  112. xp/services/conbus/actiontable/__init__.py +1 -0
  113. xp/services/conbus/actiontable/actiontable_download_service.py +158 -0
  114. xp/services/conbus/actiontable/actiontable_list_service.py +91 -0
  115. xp/services/conbus/actiontable/actiontable_show_service.py +89 -0
  116. xp/services/conbus/actiontable/actiontable_upload_service.py +211 -0
  117. xp/services/conbus/actiontable/msactiontable_service.py +232 -0
  118. xp/services/conbus/conbus_blink_all_service.py +181 -0
  119. xp/services/conbus/conbus_blink_service.py +158 -0
  120. xp/services/conbus/conbus_custom_service.py +156 -0
  121. xp/services/conbus/conbus_datapoint_queryall_service.py +182 -0
  122. xp/services/conbus/conbus_datapoint_service.py +170 -0
  123. xp/services/conbus/conbus_discover_service.py +312 -0
  124. xp/services/conbus/conbus_event_raw_service.py +181 -0
  125. xp/services/conbus/conbus_output_service.py +194 -0
  126. xp/services/conbus/conbus_raw_service.py +122 -0
  127. xp/services/conbus/conbus_receive_service.py +115 -0
  128. xp/services/conbus/conbus_scan_service.py +150 -0
  129. xp/services/conbus/write_config_service.py +194 -0
  130. xp/services/homekit/__init__.py +1 -0
  131. xp/services/homekit/homekit_cache_service.py +307 -0
  132. xp/services/homekit/homekit_conbus_service.py +93 -0
  133. xp/services/homekit/homekit_config_validator.py +310 -0
  134. xp/services/homekit/homekit_conson_validator.py +121 -0
  135. xp/services/homekit/homekit_dimminglight.py +182 -0
  136. xp/services/homekit/homekit_dimminglight_service.py +148 -0
  137. xp/services/homekit/homekit_hap_service.py +342 -0
  138. xp/services/homekit/homekit_lightbulb.py +120 -0
  139. xp/services/homekit/homekit_lightbulb_service.py +86 -0
  140. xp/services/homekit/homekit_module_service.py +56 -0
  141. xp/services/homekit/homekit_outlet.py +168 -0
  142. xp/services/homekit/homekit_outlet_service.py +121 -0
  143. xp/services/homekit/homekit_service.py +359 -0
  144. xp/services/log_file_service.py +309 -0
  145. xp/services/module_type_service.py +257 -0
  146. xp/services/protocol/__init__.py +21 -0
  147. xp/services/protocol/conbus_event_protocol.py +360 -0
  148. xp/services/protocol/conbus_protocol.py +318 -0
  149. xp/services/protocol/protocol_factory.py +78 -0
  150. xp/services/protocol/telegram_protocol.py +264 -0
  151. xp/services/reverse_proxy_service.py +435 -0
  152. xp/services/server/__init__.py +1 -0
  153. xp/services/server/base_server_service.py +366 -0
  154. xp/services/server/cp20_server_service.py +65 -0
  155. xp/services/server/device_service_factory.py +94 -0
  156. xp/services/server/server_service.py +428 -0
  157. xp/services/server/xp130_server_service.py +67 -0
  158. xp/services/server/xp20_server_service.py +92 -0
  159. xp/services/server/xp230_server_service.py +58 -0
  160. xp/services/server/xp24_server_service.py +245 -0
  161. xp/services/server/xp33_server_service.py +535 -0
  162. xp/services/telegram/__init__.py +1 -0
  163. xp/services/telegram/telegram_blink_service.py +138 -0
  164. xp/services/telegram/telegram_checksum_service.py +149 -0
  165. xp/services/telegram/telegram_datapoint_service.py +82 -0
  166. xp/services/telegram/telegram_discover_service.py +277 -0
  167. xp/services/telegram/telegram_link_number_service.py +216 -0
  168. xp/services/telegram/telegram_output_service.py +322 -0
  169. xp/services/telegram/telegram_service.py +380 -0
  170. xp/services/telegram/telegram_version_service.py +288 -0
  171. xp/utils/__init__.py +12 -0
  172. xp/utils/checksum.py +61 -0
  173. xp/utils/dependencies.py +531 -0
  174. xp/utils/event_helper.py +31 -0
  175. xp/utils/serialization.py +205 -0
  176. xp/utils/time_utils.py +134 -0
@@ -0,0 +1,535 @@
1
+ """XP33 Server Service for device emulation.
2
+
3
+ This service provides XP33-specific device emulation functionality,
4
+ including response generation and device configuration handling for
5
+ 3-channel light dimmer modules.
6
+ """
7
+
8
+ import socket
9
+ import threading
10
+ from typing import Dict, Optional
11
+
12
+ from xp.models import ModuleTypeCode
13
+ from xp.models.actiontable.msactiontable_xp33 import Xp33MsActionTable
14
+ from xp.models.telegram.datapoint_type import DataPointType
15
+ from xp.models.telegram.system_function import SystemFunction
16
+ from xp.models.telegram.system_telegram import SystemTelegram
17
+ from xp.services.actiontable.msactiontable_xp33_serializer import (
18
+ Xp33MsActionTableSerializer,
19
+ )
20
+ from xp.services.server.base_server_service import BaseServerService
21
+
22
+
23
+ class XP33ServerError(Exception):
24
+ """Raised when XP33 server operations fail."""
25
+
26
+ pass
27
+
28
+
29
+ class XP33ServerService(BaseServerService):
30
+ """
31
+ XP33 device emulation service.
32
+
33
+ Generates XP33-specific responses, handles XP33 device configuration,
34
+ and implements XP33 telegram format for 3-channel dimmer modules.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ serial_number: str,
40
+ variant: str = "XP33LR",
41
+ msactiontable_serializer: Optional[Xp33MsActionTableSerializer] = None,
42
+ ):
43
+ """Initialize XP33 server service.
44
+
45
+ Args:
46
+ serial_number: The device serial number.
47
+ variant: Device variant (XP33, XP33LR, or XP33LED).
48
+ msactiontable_serializer: MsActionTable serializer (injected via DI).
49
+ """
50
+ super().__init__(serial_number)
51
+ self.variant = variant # XP33 or XP33LR or XP33LED
52
+ self.device_type = "XP33"
53
+ self.module_type_code = ModuleTypeCode.XP33 # XP33 module type
54
+
55
+ # XP33 device characteristics (anonymized for interoperability testing)
56
+ if variant == "XP33LED":
57
+ self.firmware_version = "XP33LED_V0.00.00"
58
+ self.ean_code = "1234567890123" # Test EAN - not a real product code
59
+ self.max_power = 300 # 3 x 100VA
60
+ self.module_type_code = ModuleTypeCode.XP33LED # XP33LR module type
61
+ elif variant == "XP33LR": # XP33LR
62
+ self.firmware_version = "XP33LR_V0.00.00"
63
+ self.ean_code = "1234567890124" # Test EAN - not a real product code
64
+ self.max_power = 640 # Total 640VA
65
+ self.module_type_code = ModuleTypeCode.XP33LR # XP33LR module type
66
+ else: # XP33
67
+ self.firmware_version = "XP33_V0.04.02"
68
+ self.ean_code = "1234567890125" # Test EAN - not a real product code
69
+ self.max_power = 100 # Total 640VA
70
+ self.module_type_code = ModuleTypeCode.XP33 # XP33 module type
71
+
72
+ self.device_status = "00" # Normal status
73
+ self.link_number = 4 # 4 links configured
74
+ self.autoreport_status = True
75
+
76
+ # Channel states (3 channels, 0-100% dimming)
77
+ self.channel_states = [0, 0, 0] # All channels at 0%
78
+
79
+ # Scene configuration (4 scenes)
80
+ self.scenes = {
81
+ 1: [50, 30, 20], # Scene 1: 50%, 30%, 20%
82
+ 2: [100, 100, 100], # Scene 2: All full
83
+ 3: [25, 25, 25], # Scene 3: Low level
84
+ 4: [0, 0, 0], # Scene 4: Off
85
+ }
86
+
87
+ # Storm mode state (XP33 Storm Simulator)
88
+ self.storm_mode = False # Track if device is in storm mode
89
+ self.last_response: Optional[str] = None # Cache last response for storm replay
90
+ self.storm_thread: Optional[threading.Thread] = (
91
+ None # Background thread for storm
92
+ )
93
+ self.storm_stop_event = threading.Event() # Event to stop storm thread
94
+ self.client_sockets: set[socket.socket] = set() # All active client sockets
95
+ self.client_sockets_lock = threading.Lock() # Lock for socket set
96
+ self.storm_packets_sent = 0 # Counter for packets sent during storm
97
+
98
+ # MsActionTable support
99
+ self.msactiontable_serializer = (
100
+ msactiontable_serializer or Xp33MsActionTableSerializer()
101
+ )
102
+ self.msactiontable = self._get_default_msactiontable()
103
+
104
+ def _handle_device_specific_action_request(
105
+ self, request: SystemTelegram
106
+ ) -> Optional[str]:
107
+ """Handle XP33-specific action requests."""
108
+ telegrams = self._handle_action_channel_dimming(request.data)
109
+ self.logger.debug(f"Generated {self.device_type} action responses: {telegrams}")
110
+ return telegrams
111
+
112
+ def _handle_action_channel_dimming(self, data_value: str) -> str:
113
+ """Handle XP33-specific channel dimming action.
114
+
115
+ Args:
116
+ data_value: Action data in format channel_number:dimming_level.
117
+ E.g., "00:050" means channel 0, 50% dimming.
118
+
119
+ Returns:
120
+ Response telegram(s) - ACK/NAK, optionally with event telegram.
121
+ """
122
+ if ":" not in data_value or len(data_value) < 6:
123
+ return self._build_ack_nak_response_telegram(False)
124
+
125
+ try:
126
+ parts = data_value.split(":")
127
+ channel_number = int(parts[0])
128
+ dimming_level = int(parts[1])
129
+ except (ValueError, IndexError):
130
+ return self._build_ack_nak_response_telegram(False)
131
+
132
+ if channel_number not in range(len(self.channel_states)):
133
+ return self._build_ack_nak_response_telegram(False)
134
+
135
+ if dimming_level not in range(0, 101):
136
+ return self._build_ack_nak_response_telegram(False)
137
+
138
+ previous_level = self.channel_states[channel_number]
139
+ self.channel_states[channel_number] = dimming_level
140
+ state_changed = (previous_level == 0 and dimming_level > 0) or (
141
+ previous_level > 0 and dimming_level == 0
142
+ )
143
+
144
+ telegrams = self._build_ack_nak_response_telegram(True)
145
+ if state_changed and self.autoreport_status:
146
+ # Report dimming change event
147
+ telegrams += self._build_dimming_event_telegram(
148
+ dimming_level, channel_number
149
+ )
150
+
151
+ return telegrams
152
+
153
+ def _build_ack_nak_response_telegram(self, ack_or_nak: bool) -> str:
154
+ """Build a complete ACK or NAK response telegram with checksum.
155
+
156
+ Args:
157
+ ack_or_nak: true: ACK telegram response, false: NAK telegram response.
158
+
159
+ Returns:
160
+ The complete telegram with checksum enclosed in angle brackets.
161
+ """
162
+ data_value = (
163
+ SystemFunction.ACK.value if ack_or_nak else SystemFunction.NAK.value
164
+ )
165
+ data_part = f"R{self.serial_number}" f"F{data_value:02}D"
166
+ return self._build_response_telegram(data_part)
167
+
168
+ def _build_dimming_event_telegram(
169
+ self, dimming_level: int, channel_number: int
170
+ ) -> str:
171
+ """Build a complete dimming event telegram with checksum.
172
+
173
+ Args:
174
+ dimming_level: Dimming level 0-100%.
175
+ channel_number: Channel concerned (0-2).
176
+
177
+ Returns:
178
+ The complete event telegram with checksum enclosed in angle brackets.
179
+ """
180
+ data_value = "M" if dimming_level > 0 else "B"
181
+ data_part = (
182
+ f"E{self.module_type_code.value:02}"
183
+ f"L{self.link_number:02}"
184
+ f"I{channel_number:02}"
185
+ f"{data_value}"
186
+ )
187
+ return self._build_response_telegram(data_part)
188
+
189
+ def _handle_device_specific_data_request(
190
+ self, request: SystemTelegram
191
+ ) -> Optional[str]:
192
+ """Handle XP33-specific data requests with storm mode support."""
193
+ if not request.datapoint_type:
194
+ # Check for D99 storm trigger (not in DataPointType enum)
195
+ if request.data and request.data.startswith("99"):
196
+ return self._trigger_storm_mode()
197
+ return None
198
+
199
+ datapoint_type = request.datapoint_type
200
+
201
+ # Storm mode handling
202
+ if datapoint_type == DataPointType.MODULE_ERROR_CODE:
203
+ if self.storm_mode:
204
+ # MODULE_ERROR_CODE query stops storm
205
+ return self._exit_storm_mode()
206
+ else:
207
+ # Normal operation - return error code 00
208
+ return self._build_error_code_response("00")
209
+
210
+ # If in storm mode and not MODULE_ERROR_CODE query, ignore (background thread is sending)
211
+ if self.storm_mode:
212
+ self.logger.debug(
213
+ f"Ignoring query during storm mode for device {self.serial_number}"
214
+ )
215
+ return None # Background thread is sending storm telegrams
216
+
217
+ # Normal data request handling
218
+ handler = {
219
+ DataPointType.MODULE_OUTPUT_STATE: self._handle_read_module_output_state,
220
+ DataPointType.MODULE_STATE: self._handle_read_module_state,
221
+ DataPointType.MODULE_OPERATING_HOURS: self._handle_read_module_operating_hours,
222
+ DataPointType.MODULE_LIGHT_LEVEL: self._handle_read_light_level,
223
+ }.get(datapoint_type)
224
+ if not handler:
225
+ return None
226
+
227
+ data_value = handler()
228
+ data_part = (
229
+ f"R{self.serial_number}"
230
+ f"F02D{datapoint_type.value}"
231
+ f"{self.module_type_code.value:02}"
232
+ f"{data_value}"
233
+ )
234
+ telegram = self._build_response_telegram(data_part)
235
+
236
+ # Cache response for potential storm replay
237
+ self.last_response = telegram
238
+
239
+ self.logger.debug(
240
+ f"Generated {self.device_type} module type response: {telegram}"
241
+ )
242
+ return telegram
243
+
244
+ def _handle_read_module_output_state(self) -> str:
245
+ """Handle XP33-specific module output state.
246
+
247
+ Returns:
248
+ String representation of the output state for 3 channels.
249
+ """
250
+ return (
251
+ f"xxxxx"
252
+ f"{1 if self.channel_states[0] > 0 else 0}"
253
+ f"{1 if self.channel_states[1] > 0 else 0}"
254
+ f"{1 if self.channel_states[2] > 0 else 0}"
255
+ )
256
+
257
+ def _handle_read_module_state(self) -> str:
258
+ """Handle XP33-specific module state.
259
+
260
+ Returns:
261
+ 'ON' if any channel is active, 'OFF' otherwise.
262
+ """
263
+ if any(level > 0 for level in self.channel_states):
264
+ return "ON"
265
+ return "OFF"
266
+
267
+ def _handle_read_module_operating_hours(self) -> str:
268
+ """Handle XP33-specific module operating hours.
269
+
270
+ Returns:
271
+ Operating hours for all 3 channels.
272
+ """
273
+ return "00:000[H],01:000[H],02:000[H]"
274
+
275
+ def _handle_read_light_level(self) -> str:
276
+ """Handle XP33-specific light level reading.
277
+
278
+ Returns:
279
+ Light levels for all channels in format "00:000[%],01:000[%],02:000[%]".
280
+ """
281
+ levels = [
282
+ f"{i:02d}:{level:03d}[%]" for i, level in enumerate(self.channel_states)
283
+ ]
284
+ return ",".join(levels)
285
+
286
+ def _trigger_storm_mode(self) -> Optional[str]:
287
+ """Trigger storm mode via D99 query.
288
+
289
+ Starts a background thread that sends 2 packets per second.
290
+ If storm is already active, this is a no-op.
291
+
292
+ Returns:
293
+ None (no response - storm mode activated).
294
+ """
295
+ # If storm already active, just log and continue
296
+ if self.storm_mode and self.storm_thread and self.storm_thread.is_alive():
297
+ self.logger.debug(
298
+ f"Storm already active for device {self.serial_number}, "
299
+ f"sent {self.storm_packets_sent}/200 packets"
300
+ )
301
+ return None
302
+
303
+ if not self.last_response:
304
+ self.logger.warning(
305
+ f"Cannot trigger storm for device {self.serial_number}: "
306
+ f"no cached response"
307
+ )
308
+ return None
309
+
310
+ self.storm_mode = True
311
+ self.storm_packets_sent = 0
312
+ self.storm_stop_event.clear()
313
+
314
+ # Start background thread to send storm telegrams
315
+ self.storm_thread = threading.Thread(
316
+ target=self._storm_sender_thread,
317
+ daemon=True,
318
+ name=f"Storm-{self.serial_number}",
319
+ )
320
+ self.storm_thread.start()
321
+
322
+ self.logger.info(
323
+ f"Storm triggered via D99 query for device {self.serial_number}"
324
+ )
325
+ return None # No response when entering storm mode
326
+
327
+ def _exit_storm_mode(self) -> str:
328
+ """Exit storm mode and return error code FE.
329
+
330
+ Stops the background storm thread and returns error code.
331
+
332
+ Returns:
333
+ MODULE_ERROR_CODE response with error code FE (buffer overflow).
334
+ """
335
+ self.logger.info(
336
+ f"MODULE_ERROR_CODE query received, stopping storm for device {self.serial_number}"
337
+ )
338
+
339
+ # Signal the storm thread to stop
340
+ self.storm_stop_event.set()
341
+ self.storm_mode = False
342
+
343
+ # Wait for thread to finish (with timeout)
344
+ if self.storm_thread and self.storm_thread.is_alive():
345
+ self.storm_thread.join(timeout=1.0)
346
+
347
+ self.logger.info(
348
+ f"Storm stopped after {self.storm_packets_sent} packets for device {self.serial_number}"
349
+ )
350
+ self.logger.info(
351
+ f"Storm stopped, returning to normal operation for device {self.serial_number}"
352
+ )
353
+ return self._build_error_code_response("FE")
354
+
355
+ def _storm_sender_thread(self) -> None:
356
+ """Background thread that sends storm telegrams continuously.
357
+
358
+ Sends 2 packets per second (500ms delay) until:
359
+ - 200 packets have been sent, or
360
+ - Storm mode is stopped via stop event
361
+
362
+ The storm persists across socket disconnections. If the client disconnects
363
+ and reconnects, the storm will continue on the new connection.
364
+ """
365
+ if not self.last_response:
366
+ self.logger.error(
367
+ f"Storm thread started but missing cached response for {self.serial_number}"
368
+ )
369
+ self.storm_mode = False
370
+ return
371
+
372
+ self.logger.info(
373
+ f"Storm thread started, sending 200 duplicate telegrams at 2 packets/sec for device {self.serial_number}"
374
+ )
375
+
376
+ # Type narrowing for mypy
377
+ cached_response: str = self.last_response
378
+ max_packets = 200
379
+ packets_per_second = 2
380
+ delay_between_packets = 1.0 / packets_per_second # 0.5 seconds
381
+
382
+ try:
383
+ while (
384
+ self.storm_packets_sent < max_packets
385
+ and not self.storm_stop_event.is_set()
386
+ ):
387
+ # Wait for a valid socket (client may have disconnected and reconnected)
388
+ self.add_telegram_buffer(cached_response)
389
+ self.storm_packets_sent += 1
390
+ self.logger.debug(
391
+ f"Storm packet {self.storm_packets_sent}/{max_packets} sent for {self.serial_number}"
392
+ )
393
+
394
+ # Wait before sending next packet (0.5 seconds for 2 packets/sec)
395
+ if self.storm_packets_sent < max_packets:
396
+ self.storm_stop_event.wait(timeout=delay_between_packets)
397
+
398
+ # Log completion status
399
+ if self.storm_packets_sent >= max_packets:
400
+ self.logger.info(
401
+ f"Storm completed: sent all {self.storm_packets_sent} packets for {self.serial_number}"
402
+ )
403
+ elif self.storm_stop_event.is_set():
404
+ self.logger.info(
405
+ f"Storm stopped by error code query: sent {self.storm_packets_sent} packets for {self.serial_number}"
406
+ )
407
+
408
+ # Clean up storm mode
409
+ self.storm_mode = False
410
+
411
+ except Exception as e:
412
+ self.logger.error(
413
+ f"Unexpected error in storm thread for {self.serial_number}: {e}"
414
+ )
415
+ self.storm_mode = False
416
+
417
+ def _build_error_code_response(self, error_code: str) -> str:
418
+ """Build MODULE_ERROR_CODE response telegram.
419
+
420
+ Args:
421
+ error_code: Error code (00 = normal, FE = buffer overflow).
422
+
423
+ Returns:
424
+ The complete MODULE_ERROR_CODE response telegram.
425
+ """
426
+ data_part = (
427
+ f"R{self.serial_number}"
428
+ f"F02D{DataPointType.MODULE_ERROR_CODE.value}"
429
+ f"{error_code}"
430
+ )
431
+ telegram = self._build_response_telegram(data_part)
432
+ self.logger.debug(
433
+ f"Generated {self.device_type} error code response: {telegram}"
434
+ )
435
+ return telegram
436
+
437
+ def set_channel_dimming(self, channel: int, level: int) -> bool:
438
+ """Set individual channel dimming level.
439
+
440
+ Args:
441
+ channel: Channel number (1-3).
442
+ level: Dimming level (0-100 percent).
443
+
444
+ Returns:
445
+ True if channel was set successfully, False otherwise.
446
+ """
447
+ if 1 <= channel <= 3 and 0 <= level <= 100:
448
+ self.channel_states[channel - 1] = level
449
+ self.logger.info(f"XP33 channel {channel} set to {level}%")
450
+ return True
451
+ return False
452
+
453
+ def activate_scene(self, scene: int) -> bool:
454
+ """Activate a pre-programmed scene.
455
+
456
+ Args:
457
+ scene: Scene number (1-4).
458
+
459
+ Returns:
460
+ True if scene was activated successfully, False otherwise.
461
+ """
462
+ if scene in self.scenes:
463
+ self.channel_states = self.scenes[scene].copy()
464
+ self.logger.info(f"XP33 scene {scene} activated: {self.channel_states}")
465
+ return True
466
+ return False
467
+
468
+ def _get_msactiontable_serializer(self) -> Optional[Xp33MsActionTableSerializer]:
469
+ """Get the MsActionTable serializer for XP33.
470
+
471
+ Returns:
472
+ The XP33 MsActionTable serializer instance.
473
+ """
474
+ return self.msactiontable_serializer
475
+
476
+ def _get_msactiontable(self) -> Optional[Xp33MsActionTable]:
477
+ """Get the MsActionTable for XP33.
478
+
479
+ Returns:
480
+ The XP33 MsActionTable instance.
481
+ """
482
+ return self.msactiontable
483
+
484
+ def _get_default_msactiontable(self) -> Xp33MsActionTable:
485
+ """Generate default MsActionTable configuration.
486
+
487
+ Returns:
488
+ Default XP33 MsActionTable with all outputs at 0-100% range, no scenes configured.
489
+ """
490
+ # All outputs at 0-100% range, no scenes configured
491
+ return Xp33MsActionTable()
492
+
493
+ def get_device_info(self) -> Dict:
494
+ """Get XP33 device information.
495
+
496
+ Returns:
497
+ Dictionary containing device information.
498
+ """
499
+ return {
500
+ "serial_number": self.serial_number,
501
+ "device_type": self.device_type,
502
+ "variant": self.variant,
503
+ "firmware_version": self.firmware_version,
504
+ "ean_code": self.ean_code,
505
+ "max_power": self.max_power,
506
+ "status": self.device_status,
507
+ "link_number": self.link_number,
508
+ "autoreport_status": self.autoreport_status,
509
+ "channel_states": self.channel_states.copy(),
510
+ "available_scenes": list(self.scenes.keys()),
511
+ }
512
+
513
+ def get_technical_specs(self) -> Dict:
514
+ """Get technical specifications.
515
+
516
+ Returns:
517
+ Dictionary containing technical specifications.
518
+ """
519
+ if self.variant == "XP33LED":
520
+ return {
521
+ "power_per_channel": "100VA",
522
+ "total_power": "300VA",
523
+ "load_types": ["LED lamps", "resistive", "capacitive"],
524
+ "dimming_type": "Leading/Trailing edge configurable",
525
+ "protection": "Short-circuit proof channels",
526
+ }
527
+
528
+ # XP33LR
529
+ return {
530
+ "power_per_channel": "500VA max",
531
+ "total_power": "640VA",
532
+ "load_types": ["Resistive", "inductive"],
533
+ "dimming_type": "Leading edge, logarithmic control",
534
+ "protection": "Thermal protection, neutral break detection",
535
+ }
@@ -0,0 +1 @@
1
+ """Telegram parsing and processing services."""
@@ -0,0 +1,138 @@
1
+ """Service for blink/unblink telegram operations.
2
+
3
+ This service handles generation and parsing of blink/unblink system telegrams
4
+ used for controlling module LED status.
5
+ """
6
+
7
+ from xp.models.telegram.reply_telegram import ReplyTelegram
8
+ from xp.models.telegram.system_function import SystemFunction
9
+ from xp.models.telegram.system_telegram import SystemTelegram
10
+ from xp.utils.checksum import calculate_checksum
11
+
12
+
13
+ class BlinkError(Exception):
14
+ """Raised when blink/unblink operations fail."""
15
+
16
+ pass
17
+
18
+
19
+ class TelegramBlinkService:
20
+ """
21
+ Service for generating and handling blink/unblink system telegrams.
22
+
23
+ Handles telegrams for controlling module LED status using the F05D00 and F06D00 formats:
24
+ - Blink: <S{serial_number}F05D00{checksum}>
25
+ - Unblink: <S{serial_number}F06D00{checksum}>
26
+ """
27
+
28
+ def __init__(self) -> None:
29
+ """Initialize the blink service."""
30
+ pass
31
+
32
+ @staticmethod
33
+ def generate_blink_telegram(serial_number: str, on_or_off: str) -> str:
34
+ """Generate a telegram to start blinking a module's LED.
35
+
36
+ Args:
37
+ serial_number: The 10-digit module serial number.
38
+ on_or_off: The action to perform ('on' for blink, 'off' for unblink).
39
+
40
+ Returns:
41
+ Formatted telegram string (e.g., "<S0012345008F05D00FN>").
42
+
43
+ Raises:
44
+ BlinkError: If parameters are invalid.
45
+ """
46
+ # Validate serial number
47
+ if not serial_number or len(serial_number) != 10:
48
+ raise BlinkError(f"Serial number must be 10 digits, got: {serial_number}")
49
+
50
+ if not serial_number.isdigit():
51
+ raise BlinkError(f"Serial number must contain only digits: {serial_number}")
52
+
53
+ action_type = SystemFunction.BLINK
54
+ if on_or_off.lower() == "off":
55
+ action_type = SystemFunction.UNBLINK
56
+
57
+ # Build the data part of the telegram (F05D00 - Blink function, Status data point)
58
+ data_part = f"S{serial_number}F{action_type.value}D00"
59
+
60
+ # Calculate checksum
61
+ checksum = calculate_checksum(data_part)
62
+
63
+ # Build complete telegram
64
+ telegram = f"<{data_part}{checksum}>"
65
+
66
+ return telegram
67
+
68
+ def create_blink_telegram_object(self, serial_number: str) -> SystemTelegram:
69
+ """Create a SystemTelegram object for blinking LED.
70
+
71
+ Args:
72
+ serial_number: The 10-digit module serial number.
73
+
74
+ Returns:
75
+ SystemTelegram object representing the blink command.
76
+ """
77
+ raw_telegram = self.generate_blink_telegram(serial_number, "on")
78
+
79
+ # Extract checksum from the generated telegram
80
+ checksum = raw_telegram[-3:-1] # Get checksum before closing >
81
+
82
+ telegram = SystemTelegram(
83
+ serial_number=serial_number,
84
+ system_function=SystemFunction.BLINK,
85
+ datapoint_type=None,
86
+ checksum=checksum,
87
+ raw_telegram=raw_telegram,
88
+ )
89
+
90
+ return telegram
91
+
92
+ def create_unblink_telegram_object(self, serial_number: str) -> SystemTelegram:
93
+ """Create a SystemTelegram object for unblink LED.
94
+
95
+ Args:
96
+ serial_number: The 10-digit module serial number.
97
+
98
+ Returns:
99
+ SystemTelegram object representing the unblink command.
100
+ """
101
+ raw_telegram = self.generate_blink_telegram(serial_number, "off")
102
+
103
+ # Extract checksum from the generated telegram
104
+ checksum = raw_telegram[-3:-1] # Get checksum before closing >
105
+
106
+ telegram = SystemTelegram(
107
+ serial_number=serial_number,
108
+ system_function=SystemFunction.UNBLINK,
109
+ datapoint_type=None,
110
+ checksum=checksum,
111
+ raw_telegram=raw_telegram,
112
+ )
113
+
114
+ return telegram
115
+
116
+ @staticmethod
117
+ def is_ack_response(reply_telegram: ReplyTelegram) -> bool:
118
+ """Check if a reply telegram is an ACK response.
119
+
120
+ Args:
121
+ reply_telegram: Reply telegram to check.
122
+
123
+ Returns:
124
+ True if this is an ACK response (F18D), False otherwise.
125
+ """
126
+ return reply_telegram.system_function == SystemFunction.ACK
127
+
128
+ @staticmethod
129
+ def is_nak_response(reply_telegram: ReplyTelegram) -> bool:
130
+ """Check if a reply telegram is a NAK response.
131
+
132
+ Args:
133
+ reply_telegram: Reply telegram to check.
134
+
135
+ Returns:
136
+ True if this is a NAK response (F19D), False otherwise.
137
+ """
138
+ return reply_telegram.system_function == SystemFunction.NAK