conson-xp 1.4.0__py3-none-any.whl → 1.6.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: conson-xp
3
- Version: 1.4.0
3
+ Version: 1.6.0
4
4
  Summary: XP Protocol Communication Tools
5
5
  Author-Email: ldvchosal <ldvchosal@github.com>
6
6
  License: MIT License
@@ -1,8 +1,8 @@
1
- conson_xp-1.4.0.dist-info/METADATA,sha256=gVe0dQdYUIfvh1zZ9TLnHgL1bMgCbwaBQDlbnMTGIig,9274
2
- conson_xp-1.4.0.dist-info/WHEEL,sha256=9P2ygRxDrTJz3gsagc0Z96ukrxjr-LFBGOgv3AuKlCA,90
3
- conson_xp-1.4.0.dist-info/entry_points.txt,sha256=1OcdIcDM1hz3ljCXgybaPUh1IOFEwkaTgLIW9u9zGeg,50
4
- conson_xp-1.4.0.dist-info/licenses/LICENSE,sha256=rxj6woMM-r3YCyGq_UHFtbh7kHTAJgrccH6O-33IDE4,1419
5
- xp/__init__.py,sha256=K0b80vyQTL1NHu42mglJvDuAouKQOPKpfKPus7iPCEg,180
1
+ conson_xp-1.6.0.dist-info/METADATA,sha256=NeCIj-sEltfweIjEbTTiJMkUhm6_JDk3o4qqgj2AQy4,9274
2
+ conson_xp-1.6.0.dist-info/WHEEL,sha256=9P2ygRxDrTJz3gsagc0Z96ukrxjr-LFBGOgv3AuKlCA,90
3
+ conson_xp-1.6.0.dist-info/entry_points.txt,sha256=1OcdIcDM1hz3ljCXgybaPUh1IOFEwkaTgLIW9u9zGeg,50
4
+ conson_xp-1.6.0.dist-info/licenses/LICENSE,sha256=rxj6woMM-r3YCyGq_UHFtbh7kHTAJgrccH6O-33IDE4,1419
5
+ xp/__init__.py,sha256=ch-TVxJdSXvFaMGwGhp04iVA5GRAiAhsU9Ehnb0Y3qM,180
6
6
  xp/cli/__init__.py,sha256=QjnKB1KaI2aIyKlzrnvCwfbBuUj8HNgwNMvNJVQofbI,81
7
7
  xp/cli/__main__.py,sha256=l2iKwMdat5rTGd3JWs-uGksnYYDDffp_Npz05QdKEeU,117
8
8
  xp/cli/commands/__init__.py,sha256=02CbZoKmNX-fn5etX4Hdgg2lUt1MsLFPYx2VkXZyFJ8,4394
@@ -97,14 +97,16 @@ xp/models/telegram/telegram_type.py,sha256=GhqKP63oNMyh2tIvCPcsC5RFp4s4JjhmEqCLC
97
97
  xp/models/telegram/timeparam_type.py,sha256=Ar8xvSfPmOAgR2g2Je0FgvP01SL7bPvZn5_HrVDpmJM,1137
98
98
  xp/models/write_config_type.py,sha256=T2RaO52RpzoJ4782uMHE-fX7Ymx3CaIQAEwByydXq1M,881
99
99
  xp/services/__init__.py,sha256=W9YZyrkh7vm--ZHhAXNQiOYQs5yhhmUHXP5I0Lf1XBg,782
100
+ xp/services/actiontable/__init__.py,sha256=z6js4EuJ6xKHaseTEhuEvKo1tr9K1XyQiruReJtBiPY,26
101
+ xp/services/actiontable/actiontable_serializer.py,sha256=x45-8d5Ba9l3hX2TFC5nqKv-g_244g-VTWhXvVXL8Jg,5159
102
+ xp/services/actiontable/msactiontable_serializer.py,sha256=RRL6TZ1gpSQw81kAiw2BV3jTqm4fCJC0pWIcO26Cmos,174
103
+ xp/services/actiontable/msactiontable_xp20_serializer.py,sha256=EYspooOdi0Z8oaXGxpazwnUoTmh-d7U9auhu11iBgmU,6527
104
+ xp/services/actiontable/msactiontable_xp24_serializer.py,sha256=30qsk9UKje1n32PPc4YoGV1lw_ZvgxNqqd8ZDgzMJpg,4504
105
+ xp/services/actiontable/msactiontable_xp33_serializer.py,sha256=nuWfka4U9W4lpTcS8uD6azXFcryPb0CUO5O7Z28G1k8,8901
100
106
  xp/services/conbus/__init__.py,sha256=Hi35sMKu9o6LpYoi2tQDaQoMb8M5sOt_-LUTxxaCU_0,28
101
107
  xp/services/conbus/actiontable/__init__.py,sha256=oD6vRk_Ye-eZ9s_hldAgtRJFu4mfAnODqpkJUGHHszk,40
102
- xp/services/conbus/actiontable/actiontable_serializer.py,sha256=x45-8d5Ba9l3hX2TFC5nqKv-g_244g-VTWhXvVXL8Jg,5159
103
- xp/services/conbus/actiontable/actiontable_service.py,sha256=QJwROShPU7uoexB9GxT6if8u8Cfa8yJO3WJqAHNjqMY,5633
104
- xp/services/conbus/actiontable/msactiontable_service.py,sha256=u64nejKvHzMdmlK9VoM7P3uMGIfjyfo2xp9dXXlgvjc,7451
105
- xp/services/conbus/actiontable/msactiontable_xp20_serializer.py,sha256=EYspooOdi0Z8oaXGxpazwnUoTmh-d7U9auhu11iBgmU,6527
106
- xp/services/conbus/actiontable/msactiontable_xp24_serializer.py,sha256=30qsk9UKje1n32PPc4YoGV1lw_ZvgxNqqd8ZDgzMJpg,4504
107
- xp/services/conbus/actiontable/msactiontable_xp33_serializer.py,sha256=nuWfka4U9W4lpTcS8uD6azXFcryPb0CUO5O7Z28G1k8,8901
108
+ xp/services/conbus/actiontable/actiontable_service.py,sha256=uy-BFCsjDoe1ZuZy9cTwRSIfMSxznLEN-iMtTsPW3EI,5626
109
+ xp/services/conbus/actiontable/msactiontable_service.py,sha256=wVYFBqj7gngZ-6Kk5AsNiTYeNe3xZoPMr4-lmcbAUAE,7430
108
110
  xp/services/conbus/conbus_blink_all_service.py,sha256=OaEg4b8AEiEruHSkZ5jDtaoI81vwwxLq4KWXO7zBdD0,6582
109
111
  xp/services/conbus/conbus_blink_service.py,sha256=x9uM-sLnIEV8wSNsvJgo08E042g-Hh2ZF3rXkz-k_9s,5824
110
112
  xp/services/conbus/conbus_custom_service.py,sha256=4aneYdPObiZOGxPFYg5Wr70cl_xFxlQIdJBPQSa0enI,5826
@@ -138,14 +140,15 @@ xp/services/protocol/protocol_factory.py,sha256=PmjN9AtW9sxNo3voqUiNgQA-pTvX1RW4
138
140
  xp/services/protocol/telegram_protocol.py,sha256=Ki5DrXsKxiaqLcdP9WWUuhUI7cPu2DfwyZkh-Gv9Lb8,9496
139
141
  xp/services/reverse_proxy_service.py,sha256=BUOlcLlTU-R5iuC_96rasug21xo19wK9_4fMQXxc0QM,15061
140
142
  xp/services/server/__init__.py,sha256=QEcCj-jK0goAukJCe15TKYFQfSAzWsduPT_wW0HxZU8,48
141
- xp/services/server/base_server_service.py,sha256=6RmL7bLRqdjEZgIf7lBwwBOdrpQMWhdQnpuF01469M8,8921
142
- xp/services/server/cp20_server_service.py,sha256=PkdkORQ-aIHtQb-wuAgkRxKcdpNWpvys_p1sXJg0yoI,1679
143
- xp/services/server/server_service.py,sha256=K25vq6B83n-iwYwEsani5KLULp9DBUBIoAzrYmXmUJ8,14688
144
- xp/services/server/xp130_server_service.py,sha256=mD3vE-JDR9s_o7zjVCu4cibM8hUbwJ1oxgb_JwtQ2WU,1819
145
- xp/services/server/xp20_server_service.py,sha256=s9RrqhCZ8xtgEzc8GXTlG81b4LtZLCFy79DhzBLTPjA,1428
146
- xp/services/server/xp230_server_service.py,sha256=c3kzkA-fEOglrjLISQLbyk_rUdKzwN20hc0qtF9MEAQ,1443
147
- xp/services/server/xp24_server_service.py,sha256=_QMHe0UgxVlyB0DZmP1KPdjheT1qE8V8-EW55FM58DY,6606
148
- xp/services/server/xp33_server_service.py,sha256=DneRmJEd7oZGxC-Tj8KqAt54wEQwSUzKuCFMSEf2_bI,11069
143
+ xp/services/server/base_server_service.py,sha256=Yt1gfepRt7GLo_hsBqIzNMUye_JtEM8FupP9uTYJBUA,13294
144
+ xp/services/server/cp20_server_service.py,sha256=SXdI6Jt400T9sLdw86ovEqKRGeV3nYVaHEA9Gcj6W2A,2041
145
+ xp/services/server/device_service_factory.py,sha256=Y4TvSFALeq0zYzHfCwcbikSpmIyYbLcvm9756n5Jm7Q,3744
146
+ xp/services/server/server_service.py,sha256=N-_WVNG2J2tH-12ZLAo5SLGIHuEseZObJ6X7PhQt8Yo,16216
147
+ xp/services/server/xp130_server_service.py,sha256=YnvetDp72-QzkyDGB4qfZZIwFs03HuibUOz2zb9XR0c,2191
148
+ xp/services/server/xp20_server_service.py,sha256=1wJ7A-bRkN9O5Spu3q3LNDW31mNtNF2eNMQ5E6O2ltA,2928
149
+ xp/services/server/xp230_server_service.py,sha256=k9ftCY5tjLFP31mKVCspq283RVaPkGx-Yq61Urk8JLs,1815
150
+ xp/services/server/xp24_server_service.py,sha256=S4kDZHf6SsFTwIzk1PwkWntFHtmOuVcz6UclkRdTGsc,8670
151
+ xp/services/server/xp33_server_service.py,sha256=X5BJr7RYueHAPNrfW-HnqV7ZN-OAouKxH1qMdDADqhk,19745
149
152
  xp/services/telegram/__init__.py,sha256=kv0JgMg13Fp18WgGQpalNRAWwiWbrz18X4kZAP9xpSQ,48
150
153
  xp/services/telegram/telegram_blink_service.py,sha256=Xctc9mCSZiiW1YTh8cA-4jlc8fTioS5OxT6ymhSqiYI,4487
151
154
  xp/services/telegram/telegram_checksum_service.py,sha256=rp_C5PlraOOIyqZDp9XjBBNZLUeBLdQNNHVpN6D-1v8,4729
@@ -157,8 +160,8 @@ xp/services/telegram/telegram_service.py,sha256=CQKmwV0Jmlr1WwrshaANyp_e77DjBzXz
157
160
  xp/services/telegram/telegram_version_service.py,sha256=M5HdOTsLdcwo122FP-jW6R740ktLrtKf2TiMDVz23h8,10528
158
161
  xp/utils/__init__.py,sha256=_avMF_UOkfR3tNeDIPqQ5odmbq5raKkaq1rZ9Cn1CJs,332
159
162
  xp/utils/checksum.py,sha256=HDpiQxmdIedbCbZ4o_Box0i_Zig417BtCV_46ZyhiTk,1711
160
- xp/utils/dependencies.py,sha256=4G7r0m1HY9UV4E0zLS8L-axcNiX2mM-N6OOAU8dVHVM,17740
163
+ xp/utils/dependencies.py,sha256=xNnwVWxMThq8V9fGRgqMUJABMBaZ88NF7rW3iYBQlDE,18505
161
164
  xp/utils/event_helper.py,sha256=W-A_xmoXlpWZBbJH6qdaN50o3-XrwFsDgvAGMJDiAgo,1001
162
165
  xp/utils/serialization.py,sha256=RWHHk86feaB4ZP7rjE4qOWK0900yg2joUBDkP76gfOY,4618
163
166
  xp/utils/time_utils.py,sha256=dEyViDlAG9GWU-J3D_YVa-sGma6yiyyMTgN4h2x3PY4,3781
164
- conson_xp-1.4.0.dist-info/RECORD,,
167
+ conson_xp-1.6.0.dist-info/RECORD,,
xp/__init__.py CHANGED
@@ -3,7 +3,7 @@
3
3
  conson-xp package.
4
4
  """
5
5
 
6
- __version__ = "1.4.0"
6
+ __version__ = "1.6.0"
7
7
  __manufacturer__ = "salchichon"
8
8
  __model__ = "xp.cli"
9
9
  __serial__ = "2025.09.23.000"
@@ -0,0 +1 @@
1
+ """Action table utils."""
@@ -0,0 +1,7 @@
1
+ """Generic MsActionTable serializer base class for type hints."""
2
+
3
+
4
+ class MsActionTableSerializer:
5
+ """Serializer for ActionTable telegram encoding/decoding."""
6
+
7
+ pass
@@ -10,7 +10,7 @@ from xp.models.actiontable.actiontable import ActionTable
10
10
  from xp.models.protocol.conbus_protocol import TelegramReceivedEvent
11
11
  from xp.models.telegram.system_function import SystemFunction
12
12
  from xp.models.telegram.telegram_type import TelegramType
13
- from xp.services.conbus.actiontable.actiontable_serializer import ActionTableSerializer
13
+ from xp.services.actiontable.actiontable_serializer import ActionTableSerializer
14
14
  from xp.services.protocol import ConbusProtocol
15
15
  from xp.services.telegram.telegram_service import TelegramService
16
16
 
@@ -12,13 +12,13 @@ from xp.models.actiontable.msactiontable_xp33 import Xp33MsActionTable
12
12
  from xp.models.protocol.conbus_protocol import TelegramReceivedEvent
13
13
  from xp.models.telegram.system_function import SystemFunction
14
14
  from xp.models.telegram.telegram_type import TelegramType
15
- from xp.services.conbus.actiontable.msactiontable_xp20_serializer import (
15
+ from xp.services.actiontable.msactiontable_xp20_serializer import (
16
16
  Xp20MsActionTableSerializer,
17
17
  )
18
- from xp.services.conbus.actiontable.msactiontable_xp24_serializer import (
18
+ from xp.services.actiontable.msactiontable_xp24_serializer import (
19
19
  Xp24MsActionTableSerializer,
20
20
  )
21
- from xp.services.conbus.actiontable.msactiontable_xp33_serializer import (
21
+ from xp.services.actiontable.msactiontable_xp33_serializer import (
22
22
  Xp33MsActionTableSerializer,
23
23
  )
24
24
  from xp.services.protocol import ConbusProtocol
@@ -5,8 +5,9 @@ containing common functionality like module type response generation.
5
5
  """
6
6
 
7
7
  import logging
8
+ import threading
8
9
  from abc import ABC
9
- from typing import Optional
10
+ from typing import Any, Optional
10
11
 
11
12
  from xp.models import ModuleTypeCode
12
13
  from xp.models.telegram.datapoint_type import DataPointType
@@ -42,6 +43,12 @@ class BaseServerService(ABC):
42
43
  self.temperature: str = "+23,5§C"
43
44
  self.voltage: str = "+12,5§V"
44
45
 
46
+ self.telegram_buffer: list[str] = []
47
+ self.telegram_buffer_lock = threading.Lock() # Lock for socket set
48
+
49
+ # MsActionTable download state (None, "ack_sent", "data_sent")
50
+ self.msactiontable_download_state: Optional[str] = None
51
+
45
52
  def generate_datapoint_type_response(
46
53
  self, datapoint_type: DataPointType
47
54
  ) -> Optional[str]:
@@ -147,6 +154,83 @@ class BaseServerService(ABC):
147
154
 
148
155
  return None
149
156
 
157
+ def _get_msactiontable_serializer(self) -> Optional[Any]:
158
+ """Get the MsActionTable serializer for this device.
159
+
160
+ Subclasses should override this to return their specific serializer.
161
+
162
+ Returns:
163
+ The serializer instance, or None if not supported.
164
+ """
165
+ return None
166
+
167
+ def _get_msactiontable(self) -> Optional[Any]:
168
+ """Get the MsActionTable for this device.
169
+
170
+ Subclasses should override this to return their msactiontable instance.
171
+
172
+ Returns:
173
+ The msactiontable instance, or None if not supported.
174
+ """
175
+ return None
176
+
177
+ def _handle_download_msactiontable_request(
178
+ self, request: SystemTelegram
179
+ ) -> Optional[str]:
180
+ """Handle F13D - DOWNLOAD_MSACTIONTABLE request.
181
+
182
+ Args:
183
+ request: The system telegram request to process.
184
+
185
+ Returns:
186
+ ACK telegram if request is valid, NAK otherwise.
187
+ """
188
+ if (
189
+ request.system_function == SystemFunction.DOWNLOAD_MSACTIONTABLE
190
+ and self.msactiontable_download_state is None
191
+ ):
192
+ self.msactiontable_download_state = "ack_sent"
193
+ # Send ACK and queue data telegram
194
+ return self._build_response_telegram(f"R{self.serial_number}F18D") # ACK
195
+
196
+ return self._build_response_telegram(f"R{self.serial_number}F19D") # NAK
197
+
198
+ def _handle_download_msactiontable_ack_request(
199
+ self, _request: SystemTelegram
200
+ ) -> Optional[str]:
201
+ """Handle MsActionTable download ACK protocol.
202
+
203
+ Args:
204
+ _request: The system telegram request (unused, kept for signature consistency).
205
+
206
+ Returns:
207
+ Data telegram, EOF telegram, or NAK if state is invalid.
208
+ """
209
+ serializer = self._get_msactiontable_serializer()
210
+ msactiontable = self._get_msactiontable()
211
+
212
+ # Only handle if serializer and msactiontable are available
213
+ if not serializer or msactiontable is None:
214
+ return None
215
+
216
+ # Handle F18D - CONTINUE (after ACK or data)
217
+ if self.msactiontable_download_state == "ack_sent":
218
+ # Send MsActionTable data
219
+ encoded_data = serializer.to_data(msactiontable)
220
+ data_telegram = self._build_response_telegram(
221
+ f"R{self.serial_number}F17D{encoded_data}"
222
+ )
223
+ self.msactiontable_download_state = "data_sent"
224
+ return data_telegram
225
+
226
+ elif self.msactiontable_download_state == "data_sent":
227
+ # Send EOF
228
+ eof_telegram = self._build_response_telegram(f"R{self.serial_number}F16D")
229
+ self.msactiontable_download_state = None
230
+ return eof_telegram
231
+
232
+ return self._build_response_telegram(f"R{self.serial_number}F19D") # NAK
233
+
150
234
  def process_system_telegram(self, request: SystemTelegram) -> Optional[str]:
151
235
  """Template method for processing system telegrams.
152
236
 
@@ -173,6 +257,15 @@ class BaseServerService(ABC):
173
257
  elif request.system_function == SystemFunction.ACTION:
174
258
  return self._handle_action_request(request)
175
259
 
260
+ elif request.system_function == SystemFunction.DOWNLOAD_MSACTIONTABLE:
261
+ return self._handle_download_msactiontable_request(request)
262
+
263
+ elif (
264
+ request.system_function == SystemFunction.ACK
265
+ and self.msactiontable_download_state
266
+ ):
267
+ return self._handle_download_msactiontable_ack_request(request)
268
+
176
269
  self.logger.warning(f"Unhandled {self.device_type} request: {request}")
177
270
  return None
178
271
 
@@ -257,3 +350,28 @@ class BaseServerService(ABC):
257
350
  The response telegram string, or None if request cannot be handled.
258
351
  """
259
352
  return None
353
+
354
+ def add_telegram_buffer(self, telegram: str) -> None:
355
+ """Add telegram to the buffer.
356
+
357
+ Args:
358
+ telegram: The telegram string to add to the buffer.
359
+ """
360
+ self.logger.debug(f"Add telegram to the buffer: {telegram}")
361
+ with self.telegram_buffer_lock:
362
+ self.telegram_buffer.append(telegram)
363
+
364
+ def collect_telegram_buffer(self) -> list[str]:
365
+ """Collecting telegrams from the buffer.
366
+
367
+ Returns:
368
+ List of telegram strings from the buffer. The buffer is cleared after collection.
369
+ """
370
+ self.logger.debug(
371
+ f"Collecting {self.serial_number} telegrams from buffer: {len(self.telegram_buffer)}"
372
+ )
373
+ with self.telegram_buffer_lock:
374
+ result = self.telegram_buffer.copy()
375
+ self.logger.debug(f"Resetting {self.serial_number} buffer")
376
+ self.telegram_buffer.clear()
377
+ return result
@@ -8,6 +8,7 @@ from typing import Dict, Optional
8
8
 
9
9
  from xp.models import ModuleTypeCode
10
10
  from xp.models.telegram.system_telegram import SystemTelegram
11
+ from xp.services.actiontable.msactiontable_serializer import MsActionTableSerializer
11
12
  from xp.services.server.base_server_service import BaseServerService
12
13
 
13
14
 
@@ -25,11 +26,18 @@ class CP20ServerService(BaseServerService):
25
26
  and implements CP20 telegram format.
26
27
  """
27
28
 
28
- def __init__(self, serial_number: str):
29
+ def __init__(
30
+ self,
31
+ serial_number: str,
32
+ _variant: str = "",
33
+ _msactiontable_serializer: Optional[MsActionTableSerializer] = None,
34
+ ):
29
35
  """Initialize CP20 server service.
30
36
 
31
37
  Args:
32
38
  serial_number: The device serial number.
39
+ _variant: Reserved parameter for consistency (unused).
40
+ _msactiontable_serializer: Generic MsActionTable serializer (unused).
33
41
  """
34
42
  super().__init__(serial_number)
35
43
  self.device_type = "CP20"
@@ -0,0 +1,94 @@
1
+ """Device Service Factory for creating device instances.
2
+
3
+ This module provides a factory for creating device service instances
4
+ with proper dependency injection of serializers.
5
+ """
6
+
7
+ from xp.services.actiontable.msactiontable_serializer import MsActionTableSerializer
8
+ from xp.services.actiontable.msactiontable_xp20_serializer import (
9
+ Xp20MsActionTableSerializer,
10
+ )
11
+ from xp.services.actiontable.msactiontable_xp24_serializer import (
12
+ Xp24MsActionTableSerializer,
13
+ )
14
+ from xp.services.actiontable.msactiontable_xp33_serializer import (
15
+ Xp33MsActionTableSerializer,
16
+ )
17
+ from xp.services.server.base_server_service import BaseServerService
18
+ from xp.services.server.cp20_server_service import CP20ServerService
19
+ from xp.services.server.xp20_server_service import XP20ServerService
20
+ from xp.services.server.xp24_server_service import XP24ServerService
21
+ from xp.services.server.xp33_server_service import XP33ServerService
22
+ from xp.services.server.xp130_server_service import XP130ServerService
23
+ from xp.services.server.xp230_server_service import XP230ServerService
24
+
25
+
26
+ class DeviceServiceFactory:
27
+ """Factory for creating device service instances.
28
+
29
+ Encapsulates device creation logic and handles serializer injection
30
+ for different device types.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ xp20ms_serializer: Xp20MsActionTableSerializer,
36
+ xp24ms_serializer: Xp24MsActionTableSerializer,
37
+ xp33ms_serializer: Xp33MsActionTableSerializer,
38
+ ms_serializer: MsActionTableSerializer,
39
+ ):
40
+ """Initialize device service factory.
41
+
42
+ Args:
43
+ xp20ms_serializer: XP20 MsActionTable serializer (injected via DI).
44
+ xp24ms_serializer: XP24 MsActionTable serializer (injected via DI).
45
+ xp33ms_serializer: XP33 MsActionTable serializer (injected via DI).
46
+ ms_serializer: Generic MsActionTable serializer (injected via DI).
47
+ """
48
+ self.xp20ms_serializer = xp20ms_serializer
49
+ self.xp24ms_serializer = xp24ms_serializer
50
+ self.xp33ms_serializer = xp33ms_serializer
51
+ self.ms_serializer = ms_serializer
52
+
53
+ def create_device(self, module_type: str, serial_number: str) -> BaseServerService:
54
+ """Create device instance for given module type.
55
+
56
+ Args:
57
+ module_type: Module type code (e.g., "XP20", "XP33LR").
58
+ serial_number: Device serial number.
59
+
60
+ Returns:
61
+ Device service instance configured with appropriate serializer.
62
+
63
+ Raises:
64
+ ValueError: If module_type is unknown or unsupported.
65
+ """
66
+ # Map module types to their constructors and parameters
67
+ if module_type == "CP20":
68
+ return CP20ServerService(serial_number, "CP20", self.ms_serializer)
69
+
70
+ elif module_type == "XP24":
71
+ return XP24ServerService(serial_number, "XP24", self.xp24ms_serializer)
72
+
73
+ elif module_type == "XP33":
74
+ return XP33ServerService(serial_number, "XP33", self.xp33ms_serializer)
75
+
76
+ elif module_type == "XP33LR":
77
+ return XP33ServerService(serial_number, "XP33LR", self.xp33ms_serializer)
78
+
79
+ elif module_type == "XP33LED":
80
+ return XP33ServerService(serial_number, "XP33LED", self.xp33ms_serializer)
81
+
82
+ elif module_type == "XP20":
83
+ return XP20ServerService(serial_number, "XP20", self.xp20ms_serializer)
84
+
85
+ elif module_type == "XP130":
86
+ return XP130ServerService(serial_number, "XP130", self.ms_serializer)
87
+
88
+ elif module_type == "XP230":
89
+ return XP230ServerService(serial_number, "XP230", self.ms_serializer)
90
+
91
+ else:
92
+ raise ValueError(
93
+ f"Unknown device type '{module_type}' for serial {serial_number}"
94
+ )
@@ -8,19 +8,14 @@ import logging
8
8
  import socket
9
9
  import threading
10
10
  from pathlib import Path
11
- from typing import Dict, List, Optional, Union
11
+ from typing import Dict, List, Optional
12
12
 
13
13
  from xp.models.homekit.homekit_conson_config import (
14
14
  ConsonModuleConfig,
15
15
  ConsonModuleListConfig,
16
16
  )
17
17
  from xp.services.server.base_server_service import BaseServerService
18
- from xp.services.server.cp20_server_service import CP20ServerService
19
- from xp.services.server.xp20_server_service import XP20ServerService
20
- from xp.services.server.xp24_server_service import XP24ServerService
21
- from xp.services.server.xp33_server_service import XP33ServerService
22
- from xp.services.server.xp130_server_service import XP130ServerService
23
- from xp.services.server.xp230_server_service import XP230ServerService
18
+ from xp.services.server.device_service_factory import DeviceServiceFactory
24
19
  from xp.services.telegram.telegram_discover_service import TelegramDiscoverService
25
20
  from xp.services.telegram.telegram_service import TelegramService
26
21
 
@@ -43,6 +38,7 @@ class ServerService:
43
38
  self,
44
39
  telegram_service: TelegramService,
45
40
  discover_service: TelegramDiscoverService,
41
+ device_factory: DeviceServiceFactory,
46
42
  config_path: str = "server.yml",
47
43
  port: int = 10001,
48
44
  ):
@@ -51,25 +47,28 @@ class ServerService:
51
47
  Args:
52
48
  telegram_service: Service for parsing system telegrams.
53
49
  discover_service: Service for handling discover requests.
50
+ device_factory: Factory for creating device service instances (injected via DI).
54
51
  config_path: Path to the server configuration file.
55
52
  port: TCP port to listen on.
56
53
  """
57
54
  self.telegram_service = telegram_service
58
55
  self.discover_service = discover_service
56
+ self.device_factory = device_factory
59
57
  self.config_path = config_path
60
58
  self.port = port
61
59
  self.server_socket: Optional[socket.socket] = None
62
60
  self.is_running = False
63
61
  self.devices: List[ConsonModuleConfig] = []
64
- self.device_services: Dict[
65
- str,
66
- Union[
67
- BaseServerService,
68
- XP33ServerService,
69
- XP20ServerService,
70
- XP130ServerService,
71
- ],
72
- ] = {} # serial -> device service instance
62
+ self.device_services: Dict[str, BaseServerService] = (
63
+ {}
64
+ ) # serial -> device service instance
65
+
66
+ # Collect device buffer to broadcast to client
67
+ self.collector_thread: Optional[threading.Thread] = (
68
+ None # Background thread for storm
69
+ )
70
+ self.collector_stop_event = threading.Event() # Event to stop thread
71
+ self.collector_buffer: list[str] = [] # All collected buffers
73
72
 
74
73
  # Set up logging
75
74
  self.logger = logging.getLogger(__name__)
@@ -105,44 +104,14 @@ class ServerService:
105
104
  serial_number = module.serial_number
106
105
 
107
106
  try:
107
+ # Use factory to create device instance
108
+ self.device_services[serial_number] = self.device_factory.create_device(
109
+ module_type, serial_number
110
+ )
108
111
 
109
- # Serial number is already a string from config
110
- if module_type == "CP20":
111
- self.device_services[serial_number] = CP20ServerService(
112
- serial_number
113
- )
114
- if module_type == "XP24":
115
- self.device_services[serial_number] = XP24ServerService(
116
- serial_number
117
- )
118
- elif module_type == "XP33":
119
- self.device_services[serial_number] = XP33ServerService(
120
- serial_number, "XP33"
121
- )
122
- elif module_type == "XP33LR":
123
- self.device_services[serial_number] = XP33ServerService(
124
- serial_number, "XP33LR"
125
- )
126
- elif module_type == "XP33LED":
127
- self.device_services[serial_number] = XP33ServerService(
128
- serial_number, "XP33LED"
129
- )
130
- elif module_type == "XP20":
131
- self.device_services[serial_number] = XP20ServerService(
132
- serial_number
133
- )
134
- elif module_type == "XP130":
135
- self.device_services[serial_number] = XP130ServerService(
136
- serial_number
137
- )
138
- elif module_type == "XP230":
139
- self.device_services[serial_number] = XP230ServerService(
140
- serial_number
141
- )
142
- else:
143
- self.logger.warning(
144
- f"Unknown device type '{module_type}' for serial {serial_number}"
145
- )
112
+ except ValueError as e:
113
+ # Factory raises ValueError for unknown device types
114
+ self.logger.warning(str(e))
146
115
 
147
116
  except Exception as e:
148
117
  self.logger.error(
@@ -167,6 +136,8 @@ class ServerService:
167
136
  self.server_socket.bind(("0.0.0.0", self.port))
168
137
  self.server_socket.listen(1) # Accept single connection as per spec
169
138
 
139
+ self._start_device_collector_thread()
140
+
170
141
  self.is_running = True
171
142
  self.logger.info(f"Conbus emulator server started on port {self.port}")
172
143
  self.logger.info(
@@ -221,17 +192,44 @@ class ServerService:
221
192
  ) -> None:
222
193
  """Handle individual client connection."""
223
194
  try:
195
+
196
+ idle_timeout = 300
197
+ rcv_timeout = 10
198
+
224
199
  # Set timeout for idle connections (30 seconds as per spec)
225
- client_socket.settimeout(300.0)
200
+ client_socket.settimeout(rcv_timeout)
201
+ timeout = idle_timeout / rcv_timeout
226
202
 
227
203
  while True:
204
+
205
+ # send waiting buffer
206
+ for i in range(len(self.collector_buffer)):
207
+ buffer = self.collector_buffer.pop()
208
+ client_socket.send(buffer.encode("latin-1"))
209
+ self.logger.debug(f"Sent buffer to {client_address}")
210
+
228
211
  # Receive data from client
229
- data = client_socket.recv(1024)
212
+ self.logger.debug(f"Receiving data {client_address}")
213
+ data = None
214
+ try:
215
+ data = client_socket.recv(1024)
216
+ except socket.timeout:
217
+ self.logger.debug(
218
+ f"Timeout receiving data {client_address} ({timeout})"
219
+ )
220
+ finally:
221
+ timeout -= 1
222
+
230
223
  if not data:
231
- break
224
+ if timeout <= 0:
225
+ break
226
+ continue
227
+
228
+ # reset timeout on receiving data
229
+ timeout = idle_timeout / rcv_timeout
232
230
 
233
231
  message = data.decode("latin-1").strip()
234
- self.logger.info(f"Received from {client_address}: {message}")
232
+ self.logger.debug(f"Received from {client_address}: {message}")
235
233
 
236
234
  # Process request (discover or data request)
237
235
  responses = self._process_request(message)
@@ -239,10 +237,10 @@ class ServerService:
239
237
  # Send responses
240
238
  for response in responses:
241
239
  client_socket.send(response.encode("latin-1"))
242
- self.logger.info(f"Sent to {client_address}: {response[:-1]}")
240
+ self.logger.debug(f"Sent to {client_address}: {response[:-1]}")
243
241
 
244
242
  except socket.timeout:
245
- self.logger.info(f"Client {client_address} timed out")
243
+ self.logger.debug(f"Client {client_address} timed out")
246
244
  except Exception as e:
247
245
  self.logger.error(f"Error handling client {client_address}: {e}")
248
246
  finally:
@@ -390,3 +388,48 @@ class ServerService:
390
388
  self.logger.info(
391
389
  f"Configuration reloaded: {len(self.devices)} devices, {len(self.device_services)} services"
392
390
  )
391
+
392
+ def _start_device_collector_thread(self) -> None:
393
+ """Start device buffer collector thread."""
394
+ if self.collector_thread and self.collector_thread.is_alive():
395
+ self.logger.debug("Collector thread already running")
396
+ return
397
+
398
+ # Start background thread to send storm telegrams
399
+ self.collector_thread = threading.Thread(
400
+ target=self._device_collector_thread, daemon=True, name="DeviceCollector"
401
+ )
402
+ self.collector_thread.start()
403
+ self.logger.info("Collector thread started")
404
+
405
+ def _stop_device_collector_thread(self) -> None:
406
+ """Stop device buffer collector thread."""
407
+ if not self.collector_thread or not self.collector_thread.is_alive():
408
+ self.logger.debug("Collector thread not running")
409
+ return
410
+
411
+ self.logger.info(f"Stopping collector thread: {self.collector_thread.name}")
412
+
413
+ # Wait for thread to finish (with timeout)
414
+ if self.collector_thread and self.collector_thread.is_alive():
415
+ self.collector_thread.join(timeout=1.0)
416
+
417
+ self.logger.info("Collector stopped.")
418
+
419
+ def _device_collector_thread(self) -> None:
420
+ """Device buffer collector thread."""
421
+ self.logger.info("Collector thread starting")
422
+
423
+ while True:
424
+ self.logger.debug(
425
+ f"Collector thread collecting ({len(self.collector_buffer)})"
426
+ )
427
+ collected = 0
428
+ for device_service in self.device_services.values():
429
+ telegram_buffer = device_service.collect_telegram_buffer()
430
+ self.collector_buffer.extend(telegram_buffer)
431
+ collected += len(telegram_buffer)
432
+
433
+ # Wait a bit before checking again
434
+ self.logger.debug(f"Collector thread collected ({collected})")
435
+ self.collector_stop_event.wait(timeout=1)
@@ -5,9 +5,10 @@ including response generation and device configuration handling.
5
5
  XP130 is an Ethernet/TCPIP interface module.
6
6
  """
7
7
 
8
- from typing import Dict
8
+ from typing import Dict, Optional
9
9
 
10
10
  from xp.models import ModuleTypeCode
11
+ from xp.services.actiontable.msactiontable_serializer import MsActionTableSerializer
11
12
  from xp.services.server.base_server_service import BaseServerService
12
13
 
13
14
 
@@ -25,11 +26,18 @@ class XP130ServerService(BaseServerService):
25
26
  and implements XP130 telegram format for Ethernet/TCPIP interface module.
26
27
  """
27
28
 
28
- def __init__(self, serial_number: str):
29
+ def __init__(
30
+ self,
31
+ serial_number: str,
32
+ _variant: str = "",
33
+ _msactiontable_serializer: Optional[MsActionTableSerializer] = None,
34
+ ):
29
35
  """Initialize XP130 server service.
30
36
 
31
37
  Args:
32
38
  serial_number: The device serial number.
39
+ _variant: Reserved parameter for consistency (unused).
40
+ _msactiontable_serializer: Generic MsActionTable serializer (unused).
33
41
  """
34
42
  super().__init__(serial_number)
35
43
  self.device_type = "XP130"
@@ -4,9 +4,13 @@ This service provides XP20-specific device emulation functionality,
4
4
  including response generation and device configuration handling.
5
5
  """
6
6
 
7
- from typing import Dict
7
+ from typing import Dict, Optional
8
8
 
9
9
  from xp.models import ModuleTypeCode
10
+ from xp.models.actiontable.msactiontable_xp20 import Xp20MsActionTable
11
+ from xp.services.actiontable.msactiontable_xp20_serializer import (
12
+ Xp20MsActionTableSerializer,
13
+ )
10
14
  from xp.services.server.base_server_service import BaseServerService
11
15
 
12
16
 
@@ -24,17 +28,55 @@ class XP20ServerService(BaseServerService):
24
28
  and implements XP20 telegram format.
25
29
  """
26
30
 
27
- def __init__(self, serial_number: str):
31
+ def __init__(
32
+ self,
33
+ serial_number: str,
34
+ _variant: str = "",
35
+ msactiontable_serializer: Optional[Xp20MsActionTableSerializer] = None,
36
+ ):
28
37
  """Initialize XP20 server service.
29
38
 
30
39
  Args:
31
40
  serial_number: The device serial number.
41
+ _variant: Reserved parameter for consistency (unused).
42
+ msactiontable_serializer: MsActionTable serializer (injected via DI).
32
43
  """
33
44
  super().__init__(serial_number)
34
45
  self.device_type = "XP20"
35
46
  self.module_type_code = ModuleTypeCode.XP20 # XP20 module type from registry
36
47
  self.firmware_version = "XP20_V0.01.05"
37
48
 
49
+ # MsActionTable support
50
+ self.msactiontable_serializer = (
51
+ msactiontable_serializer or Xp20MsActionTableSerializer()
52
+ )
53
+ self.msactiontable = self._get_default_msactiontable()
54
+
55
+ def _get_msactiontable_serializer(self) -> Optional[Xp20MsActionTableSerializer]:
56
+ """Get the MsActionTable serializer for XP20.
57
+
58
+ Returns:
59
+ The XP20 MsActionTable serializer instance.
60
+ """
61
+ return self.msactiontable_serializer
62
+
63
+ def _get_msactiontable(self) -> Optional[Xp20MsActionTable]:
64
+ """Get the MsActionTable for XP20.
65
+
66
+ Returns:
67
+ The XP20 MsActionTable instance.
68
+ """
69
+ return self.msactiontable
70
+
71
+ def _get_default_msactiontable(self) -> Xp20MsActionTable:
72
+ """Generate default MsActionTable configuration.
73
+
74
+ Returns:
75
+ Default XP20 MsActionTable with all inputs unconfigured.
76
+ """
77
+ # All inputs unconfigured (all flags False, AND functions empty)
78
+ return Xp20MsActionTable()
79
+
38
80
  def get_device_info(self) -> Dict:
39
81
  """Get XP20 device information.
40
82
 
@@ -4,9 +4,10 @@ This service provides XP230-specific device emulation functionality,
4
4
  including response generation and device configuration handling.
5
5
  """
6
6
 
7
- from typing import Dict
7
+ from typing import Dict, Optional
8
8
 
9
9
  from xp.models import ModuleTypeCode
10
+ from xp.services.actiontable.msactiontable_serializer import MsActionTableSerializer
10
11
  from xp.services.server.base_server_service import BaseServerService
11
12
 
12
13
 
@@ -24,11 +25,18 @@ class XP230ServerService(BaseServerService):
24
25
  and implements XP230 telegram format.
25
26
  """
26
27
 
27
- def __init__(self, serial_number: str):
28
+ def __init__(
29
+ self,
30
+ serial_number: str,
31
+ _variant: str = "",
32
+ _msactiontable_serializer: Optional[MsActionTableSerializer] = None,
33
+ ):
28
34
  """Initialize XP230 server service.
29
35
 
30
36
  Args:
31
37
  serial_number: The device serial number.
38
+ _variant: Reserved parameter for consistency (unused).
39
+ _msactiontable_serializer: Generic MsActionTable serializer (unused).
32
40
  """
33
41
  super().__init__(serial_number)
34
42
  self.device_type = "XP230"
@@ -7,9 +7,15 @@ including response generation and device configuration handling.
7
7
  from typing import Dict, Optional
8
8
 
9
9
  from xp.models import ModuleTypeCode
10
+ from xp.models.actiontable.msactiontable_xp24 import InputAction, Xp24MsActionTable
10
11
  from xp.models.telegram.datapoint_type import DataPointType
12
+ from xp.models.telegram.input_action_type import InputActionType
11
13
  from xp.models.telegram.system_function import SystemFunction
12
14
  from xp.models.telegram.system_telegram import SystemTelegram
15
+ from xp.models.telegram.timeparam_type import TimeParam
16
+ from xp.services.actiontable.msactiontable_xp24_serializer import (
17
+ Xp24MsActionTableSerializer,
18
+ )
13
19
  from xp.services.server.base_server_service import BaseServerService
14
20
 
15
21
 
@@ -37,11 +43,18 @@ class XP24ServerService(BaseServerService):
37
43
  and implements XP24 telegram format.
38
44
  """
39
45
 
40
- def __init__(self, serial_number: str):
46
+ def __init__(
47
+ self,
48
+ serial_number: str,
49
+ _variant: str = "",
50
+ msactiontable_serializer: Optional[Xp24MsActionTableSerializer] = None,
51
+ ):
41
52
  """Initialize XP24 server service.
42
53
 
43
54
  Args:
44
55
  serial_number: The device serial number.
56
+ _variant: Reserved parameter for consistency (unused).
57
+ msactiontable_serializer: MsActionTable serializer (injected via DI).
45
58
  """
46
59
  super().__init__(serial_number)
47
60
  self.device_type = "XP24"
@@ -53,6 +66,12 @@ class XP24ServerService(BaseServerService):
53
66
  self.output_2: XP24Output = XP24Output()
54
67
  self.output_3: XP24Output = XP24Output()
55
68
 
69
+ # MsActionTable support
70
+ self.msactiontable_serializer = (
71
+ msactiontable_serializer or Xp24MsActionTableSerializer()
72
+ )
73
+ self.msactiontable = self._get_default_msactiontable()
74
+
56
75
  def _handle_device_specific_action_request(
57
76
  self, request: SystemTelegram
58
77
  ) -> Optional[str]:
@@ -175,6 +194,40 @@ class XP24ServerService(BaseServerService):
175
194
  f"{1 if self.output_3.state else 0}"
176
195
  )
177
196
 
197
+ def _get_msactiontable_serializer(self) -> Optional[Xp24MsActionTableSerializer]:
198
+ """Get the MsActionTable serializer for XP24.
199
+
200
+ Returns:
201
+ The XP24 MsActionTable serializer instance.
202
+ """
203
+ return self.msactiontable_serializer
204
+
205
+ def _get_msactiontable(self) -> Optional[Xp24MsActionTable]:
206
+ """Get the MsActionTable for XP24.
207
+
208
+ Returns:
209
+ The XP24 MsActionTable instance.
210
+ """
211
+ return self.msactiontable
212
+
213
+ def _get_default_msactiontable(self) -> Xp24MsActionTable:
214
+ """Generate default MsActionTable configuration.
215
+
216
+ Returns:
217
+ Default XP24 MsActionTable with all inputs set to VOID.
218
+ """
219
+ return Xp24MsActionTable(
220
+ input1_action=InputAction(type=InputActionType.VOID, param=TimeParam.NONE),
221
+ input2_action=InputAction(type=InputActionType.VOID, param=TimeParam.NONE),
222
+ input3_action=InputAction(type=InputActionType.VOID, param=TimeParam.NONE),
223
+ input4_action=InputAction(type=InputActionType.VOID, param=TimeParam.NONE),
224
+ mutex12=False,
225
+ mutex34=False,
226
+ curtain12=False,
227
+ curtain34=False,
228
+ mutual_deadtime=12, # MS300
229
+ )
230
+
178
231
  def get_device_info(self) -> Dict:
179
232
  """Get XP24 device information.
180
233
 
@@ -5,12 +5,18 @@ including response generation and device configuration handling for
5
5
  3-channel light dimmer modules.
6
6
  """
7
7
 
8
+ import socket
9
+ import threading
8
10
  from typing import Dict, Optional
9
11
 
10
12
  from xp.models import ModuleTypeCode
13
+ from xp.models.actiontable.msactiontable_xp33 import Xp33MsActionTable
11
14
  from xp.models.telegram.datapoint_type import DataPointType
12
15
  from xp.models.telegram.system_function import SystemFunction
13
16
  from xp.models.telegram.system_telegram import SystemTelegram
17
+ from xp.services.actiontable.msactiontable_xp33_serializer import (
18
+ Xp33MsActionTableSerializer,
19
+ )
14
20
  from xp.services.server.base_server_service import BaseServerService
15
21
 
16
22
 
@@ -28,12 +34,18 @@ class XP33ServerService(BaseServerService):
28
34
  and implements XP33 telegram format for 3-channel dimmer modules.
29
35
  """
30
36
 
31
- def __init__(self, serial_number: str, variant: str = "XP33LR"):
37
+ def __init__(
38
+ self,
39
+ serial_number: str,
40
+ variant: str = "XP33LR",
41
+ msactiontable_serializer: Optional[Xp33MsActionTableSerializer] = None,
42
+ ):
32
43
  """Initialize XP33 server service.
33
44
 
34
45
  Args:
35
46
  serial_number: The device serial number.
36
47
  variant: Device variant (XP33, XP33LR, or XP33LED).
48
+ msactiontable_serializer: MsActionTable serializer (injected via DI).
37
49
  """
38
50
  super().__init__(serial_number)
39
51
  self.variant = variant # XP33 or XP33LR or XP33LED
@@ -72,6 +84,23 @@ class XP33ServerService(BaseServerService):
72
84
  4: [0, 0, 0], # Scene 4: Off
73
85
  }
74
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
+
75
104
  def _handle_device_specific_action_request(
76
105
  self, request: SystemTelegram
77
106
  ) -> Optional[str]:
@@ -160,11 +189,32 @@ class XP33ServerService(BaseServerService):
160
189
  def _handle_device_specific_data_request(
161
190
  self, request: SystemTelegram
162
191
  ) -> Optional[str]:
163
- """Handle XP33-specific data requests."""
192
+ """Handle XP33-specific data requests with storm mode support."""
164
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()
165
197
  return None
166
198
 
167
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
168
218
  handler = {
169
219
  DataPointType.MODULE_OUTPUT_STATE: self._handle_read_module_output_state,
170
220
  DataPointType.MODULE_STATE: self._handle_read_module_state,
@@ -183,6 +233,9 @@ class XP33ServerService(BaseServerService):
183
233
  )
184
234
  telegram = self._build_response_telegram(data_part)
185
235
 
236
+ # Cache response for potential storm replay
237
+ self.last_response = telegram
238
+
186
239
  self.logger.debug(
187
240
  f"Generated {self.device_type} module type response: {telegram}"
188
241
  )
@@ -230,6 +283,157 @@ class XP33ServerService(BaseServerService):
230
283
  ]
231
284
  return ",".join(levels)
232
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
+
233
437
  def set_channel_dimming(self, channel: int, level: int) -> bool:
234
438
  """Set individual channel dimming level.
235
439
 
@@ -261,6 +465,31 @@ class XP33ServerService(BaseServerService):
261
465
  return True
262
466
  return False
263
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
+
264
493
  def get_device_info(self) -> Dict:
265
494
  """Get XP33 device information.
266
495
 
xp/utils/dependencies.py CHANGED
@@ -9,18 +9,19 @@ from twisted.internet.posixbase import PosixReactorBase
9
9
  from xp.models import ConbusClientConfig
10
10
  from xp.models.homekit.homekit_config import HomekitConfig
11
11
  from xp.models.homekit.homekit_conson_config import ConsonModuleListConfig
12
- from xp.services.conbus.actiontable.actiontable_serializer import ActionTableSerializer
13
- from xp.services.conbus.actiontable.actiontable_service import ActionTableService
14
- from xp.services.conbus.actiontable.msactiontable_service import MsActionTableService
15
- from xp.services.conbus.actiontable.msactiontable_xp20_serializer import (
12
+ from xp.services.actiontable.actiontable_serializer import ActionTableSerializer
13
+ from xp.services.actiontable.msactiontable_serializer import MsActionTableSerializer
14
+ from xp.services.actiontable.msactiontable_xp20_serializer import (
16
15
  Xp20MsActionTableSerializer,
17
16
  )
18
- from xp.services.conbus.actiontable.msactiontable_xp24_serializer import (
17
+ from xp.services.actiontable.msactiontable_xp24_serializer import (
19
18
  Xp24MsActionTableSerializer,
20
19
  )
21
- from xp.services.conbus.actiontable.msactiontable_xp33_serializer import (
20
+ from xp.services.actiontable.msactiontable_xp33_serializer import (
22
21
  Xp33MsActionTableSerializer,
23
22
  )
23
+ from xp.services.conbus.actiontable.actiontable_service import ActionTableService
24
+ from xp.services.conbus.actiontable.msactiontable_service import MsActionTableService
24
25
  from xp.services.conbus.conbus_blink_all_service import ConbusBlinkAllService
25
26
  from xp.services.conbus.conbus_blink_service import ConbusBlinkService
26
27
  from xp.services.conbus.conbus_custom_service import ConbusCustomService
@@ -49,6 +50,7 @@ from xp.services.module_type_service import ModuleTypeService
49
50
  from xp.services.protocol.protocol_factory import TelegramFactory
50
51
  from xp.services.protocol.telegram_protocol import TelegramProtocol
51
52
  from xp.services.reverse_proxy_service import ReverseProxyService
53
+ from xp.services.server.device_service_factory import DeviceServiceFactory
52
54
  from xp.services.server.server_service import ServerService
53
55
  from xp.services.telegram.telegram_blink_service import TelegramBlinkService
54
56
  from xp.services.telegram.telegram_discover_service import TelegramDiscoverService
@@ -324,12 +326,25 @@ class ServiceContainer:
324
326
  # Module type services layer
325
327
  self.container.register(ModuleTypeService, scope=punq.Scope.singleton)
326
328
 
329
+ # Device service factory
330
+ self.container.register(
331
+ DeviceServiceFactory,
332
+ factory=lambda: DeviceServiceFactory(
333
+ xp20ms_serializer=self.container.resolve(Xp20MsActionTableSerializer),
334
+ xp24ms_serializer=self.container.resolve(Xp24MsActionTableSerializer),
335
+ xp33ms_serializer=self.container.resolve(Xp33MsActionTableSerializer),
336
+ ms_serializer=self.container.resolve(MsActionTableSerializer),
337
+ ),
338
+ scope=punq.Scope.singleton,
339
+ )
340
+
327
341
  # Server services layer
328
342
  self.container.register(
329
343
  ServerService,
330
344
  factory=lambda: ServerService(
331
345
  telegram_service=self.container.resolve(TelegramService),
332
346
  discover_service=self.container.resolve(TelegramDiscoverService),
347
+ device_factory=self.container.resolve(DeviceServiceFactory),
333
348
  config_path="server.yml",
334
349
  port=self._server_port,
335
350
  ),