conson-xp 1.4.0__py3-none-any.whl → 1.5.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.5.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.5.0.dist-info/METADATA,sha256=to2xeqnuavfCJCA0S4VC3trJaxL02i7c-4RQuU-1qjE,9274
2
+ conson_xp-1.5.0.dist-info/WHEEL,sha256=9P2ygRxDrTJz3gsagc0Z96ukrxjr-LFBGOgv3AuKlCA,90
3
+ conson_xp-1.5.0.dist-info/entry_points.txt,sha256=1OcdIcDM1hz3ljCXgybaPUh1IOFEwkaTgLIW9u9zGeg,50
4
+ conson_xp-1.5.0.dist-info/licenses/LICENSE,sha256=rxj6woMM-r3YCyGq_UHFtbh7kHTAJgrccH6O-33IDE4,1419
5
+ xp/__init__.py,sha256=UqV_T6LhENt9Tn3-NIiQjKGRaHZSmX5DPf-StL2UAO0,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
@@ -138,14 +138,14 @@ xp/services/protocol/protocol_factory.py,sha256=PmjN9AtW9sxNo3voqUiNgQA-pTvX1RW4
138
138
  xp/services/protocol/telegram_protocol.py,sha256=Ki5DrXsKxiaqLcdP9WWUuhUI7cPu2DfwyZkh-Gv9Lb8,9496
139
139
  xp/services/reverse_proxy_service.py,sha256=BUOlcLlTU-R5iuC_96rasug21xo19wK9_4fMQXxc0QM,15061
140
140
  xp/services/server/__init__.py,sha256=QEcCj-jK0goAukJCe15TKYFQfSAzWsduPT_wW0HxZU8,48
141
- xp/services/server/base_server_service.py,sha256=6RmL7bLRqdjEZgIf7lBwwBOdrpQMWhdQnpuF01469M8,8921
141
+ xp/services/server/base_server_service.py,sha256=AkeLWMOTasIIiBBGM_uTCXJ31yG1ciF98b9xKyq8VSs,9997
142
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
143
+ xp/services/server/server_service.py,sha256=pRE_hdAlQfQBj10Y15IiBOz2wQL9LghoigZvcywbnKI,17899
144
144
  xp/services/server/xp130_server_service.py,sha256=mD3vE-JDR9s_o7zjVCu4cibM8hUbwJ1oxgb_JwtQ2WU,1819
145
145
  xp/services/server/xp20_server_service.py,sha256=s9RrqhCZ8xtgEzc8GXTlG81b4LtZLCFy79DhzBLTPjA,1428
146
146
  xp/services/server/xp230_server_service.py,sha256=c3kzkA-fEOglrjLISQLbyk_rUdKzwN20hc0qtF9MEAQ,1443
147
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
148
+ xp/services/server/xp33_server_service.py,sha256=BhZaphtb1hgedTmB1bTuLlWTIZp3AOuec2gffDvKmRM,18329
149
149
  xp/services/telegram/__init__.py,sha256=kv0JgMg13Fp18WgGQpalNRAWwiWbrz18X4kZAP9xpSQ,48
150
150
  xp/services/telegram/telegram_blink_service.py,sha256=Xctc9mCSZiiW1YTh8cA-4jlc8fTioS5OxT6ymhSqiYI,4487
151
151
  xp/services/telegram/telegram_checksum_service.py,sha256=rp_C5PlraOOIyqZDp9XjBBNZLUeBLdQNNHVpN6D-1v8,4729
@@ -161,4 +161,4 @@ xp/utils/dependencies.py,sha256=4G7r0m1HY9UV4E0zLS8L-axcNiX2mM-N6OOAU8dVHVM,1774
161
161
  xp/utils/event_helper.py,sha256=W-A_xmoXlpWZBbJH6qdaN50o3-XrwFsDgvAGMJDiAgo,1001
162
162
  xp/utils/serialization.py,sha256=RWHHk86feaB4ZP7rjE4qOWK0900yg2joUBDkP76gfOY,4618
163
163
  xp/utils/time_utils.py,sha256=dEyViDlAG9GWU-J3D_YVa-sGma6yiyyMTgN4h2x3PY4,3781
164
- conson_xp-1.4.0.dist-info/RECORD,,
164
+ conson_xp-1.5.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.5.0"
7
7
  __manufacturer__ = "salchichon"
8
8
  __model__ = "xp.cli"
9
9
  __serial__ = "2025.09.23.000"
@@ -5,6 +5,7 @@ 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
10
  from typing import Optional
10
11
 
@@ -42,6 +43,9 @@ 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
+
45
49
  def generate_datapoint_type_response(
46
50
  self, datapoint_type: DataPointType
47
51
  ) -> Optional[str]:
@@ -257,3 +261,28 @@ class BaseServerService(ABC):
257
261
  The response telegram string, or None if request cannot be handled.
258
262
  """
259
263
  return None
264
+
265
+ def add_telegram_buffer(self, telegram: str) -> None:
266
+ """Add telegram to the buffer.
267
+
268
+ Args:
269
+ telegram: The telegram string to add to the buffer.
270
+ """
271
+ self.logger.debug(f"Add telegram to the buffer: {telegram}")
272
+ with self.telegram_buffer_lock:
273
+ self.telegram_buffer.append(telegram)
274
+
275
+ def collect_telegram_buffer(self) -> list[str]:
276
+ """Collecting telegrams from the buffer.
277
+
278
+ Returns:
279
+ List of telegram strings from the buffer. The buffer is cleared after collection.
280
+ """
281
+ self.logger.debug(
282
+ f"Collecting {self.serial_number} telegrams from buffer: {len(self.telegram_buffer)}"
283
+ )
284
+ with self.telegram_buffer_lock:
285
+ result = self.telegram_buffer.copy()
286
+ self.logger.debug(f"Resetting {self.serial_number} buffer")
287
+ self.telegram_buffer.clear()
288
+ return result
@@ -71,6 +71,13 @@ class ServerService:
71
71
  ],
72
72
  ] = {} # serial -> device service instance
73
73
 
74
+ # Collect device buffer to broadcast to client
75
+ self.collector_thread: Optional[threading.Thread] = (
76
+ None # Background thread for storm
77
+ )
78
+ self.collector_stop_event = threading.Event() # Event to stop thread
79
+ self.collector_buffer: list[str] = [] # All collected buffers
80
+
74
81
  # Set up logging
75
82
  self.logger = logging.getLogger(__name__)
76
83
 
@@ -167,6 +174,8 @@ class ServerService:
167
174
  self.server_socket.bind(("0.0.0.0", self.port))
168
175
  self.server_socket.listen(1) # Accept single connection as per spec
169
176
 
177
+ self._start_device_collector_thread()
178
+
170
179
  self.is_running = True
171
180
  self.logger.info(f"Conbus emulator server started on port {self.port}")
172
181
  self.logger.info(
@@ -221,17 +230,44 @@ class ServerService:
221
230
  ) -> None:
222
231
  """Handle individual client connection."""
223
232
  try:
233
+
234
+ idle_timeout = 300
235
+ rcv_timeout = 10
236
+
224
237
  # Set timeout for idle connections (30 seconds as per spec)
225
- client_socket.settimeout(300.0)
238
+ client_socket.settimeout(rcv_timeout)
239
+ timeout = idle_timeout / rcv_timeout
226
240
 
227
241
  while True:
242
+
243
+ # send waiting buffer
244
+ for i in range(len(self.collector_buffer)):
245
+ buffer = self.collector_buffer.pop()
246
+ client_socket.send(buffer.encode("latin-1"))
247
+ self.logger.debug(f"Sent buffer to {client_address}")
248
+
228
249
  # Receive data from client
229
- data = client_socket.recv(1024)
250
+ self.logger.debug(f"Receiving data {client_address}")
251
+ data = None
252
+ try:
253
+ data = client_socket.recv(1024)
254
+ except socket.timeout:
255
+ self.logger.debug(
256
+ f"Timeout receiving data {client_address} ({timeout})"
257
+ )
258
+ finally:
259
+ timeout -= 1
260
+
230
261
  if not data:
231
- break
262
+ if timeout <= 0:
263
+ break
264
+ continue
265
+
266
+ # reset timeout on receiving data
267
+ timeout = idle_timeout / rcv_timeout
232
268
 
233
269
  message = data.decode("latin-1").strip()
234
- self.logger.info(f"Received from {client_address}: {message}")
270
+ self.logger.debug(f"Received from {client_address}: {message}")
235
271
 
236
272
  # Process request (discover or data request)
237
273
  responses = self._process_request(message)
@@ -239,10 +275,10 @@ class ServerService:
239
275
  # Send responses
240
276
  for response in responses:
241
277
  client_socket.send(response.encode("latin-1"))
242
- self.logger.info(f"Sent to {client_address}: {response[:-1]}")
278
+ self.logger.debug(f"Sent to {client_address}: {response[:-1]}")
243
279
 
244
280
  except socket.timeout:
245
- self.logger.info(f"Client {client_address} timed out")
281
+ self.logger.debug(f"Client {client_address} timed out")
246
282
  except Exception as e:
247
283
  self.logger.error(f"Error handling client {client_address}: {e}")
248
284
  finally:
@@ -390,3 +426,48 @@ class ServerService:
390
426
  self.logger.info(
391
427
  f"Configuration reloaded: {len(self.devices)} devices, {len(self.device_services)} services"
392
428
  )
429
+
430
+ def _start_device_collector_thread(self) -> None:
431
+ """Start device buffer collector thread."""
432
+ if self.collector_thread and self.collector_thread.is_alive():
433
+ self.logger.debug("Collector thread already running")
434
+ return
435
+
436
+ # Start background thread to send storm telegrams
437
+ self.collector_thread = threading.Thread(
438
+ target=self._device_collector_thread, daemon=True, name="DeviceCollector"
439
+ )
440
+ self.collector_thread.start()
441
+ self.logger.info("Collector thread started")
442
+
443
+ def _stop_device_collector_thread(self) -> None:
444
+ """Stop device buffer collector thread."""
445
+ if not self.collector_thread or not self.collector_thread.is_alive():
446
+ self.logger.debug("Collector thread not running")
447
+ return
448
+
449
+ self.logger.info(f"Stopping collector thread: {self.collector_thread.name}")
450
+
451
+ # Wait for thread to finish (with timeout)
452
+ if self.collector_thread and self.collector_thread.is_alive():
453
+ self.collector_thread.join(timeout=1.0)
454
+
455
+ self.logger.info("Collector stopped.")
456
+
457
+ def _device_collector_thread(self) -> None:
458
+ """Device buffer collector thread."""
459
+ self.logger.info("Collector thread starting")
460
+
461
+ while True:
462
+ self.logger.debug(
463
+ f"Collector thread collecting ({len(self.collector_buffer)})"
464
+ )
465
+ collected = 0
466
+ for device_service in self.device_services.values():
467
+ telegram_buffer = device_service.collect_telegram_buffer()
468
+ self.collector_buffer.extend(telegram_buffer)
469
+ collected += len(telegram_buffer)
470
+
471
+ # Wait a bit before checking again
472
+ self.logger.debug(f"Collector thread collected ({collected})")
473
+ self.collector_stop_event.wait(timeout=1)
@@ -5,6 +5,8 @@ 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
@@ -72,6 +74,17 @@ class XP33ServerService(BaseServerService):
72
74
  4: [0, 0, 0], # Scene 4: Off
73
75
  }
74
76
 
77
+ # Storm mode state (XP33 Storm Simulator)
78
+ self.storm_mode = False # Track if device is in storm mode
79
+ self.last_response: Optional[str] = None # Cache last response for storm replay
80
+ self.storm_thread: Optional[threading.Thread] = (
81
+ None # Background thread for storm
82
+ )
83
+ self.storm_stop_event = threading.Event() # Event to stop storm thread
84
+ self.client_sockets: set[socket.socket] = set() # All active client sockets
85
+ self.client_sockets_lock = threading.Lock() # Lock for socket set
86
+ self.storm_packets_sent = 0 # Counter for packets sent during storm
87
+
75
88
  def _handle_device_specific_action_request(
76
89
  self, request: SystemTelegram
77
90
  ) -> Optional[str]:
@@ -160,11 +173,32 @@ class XP33ServerService(BaseServerService):
160
173
  def _handle_device_specific_data_request(
161
174
  self, request: SystemTelegram
162
175
  ) -> Optional[str]:
163
- """Handle XP33-specific data requests."""
176
+ """Handle XP33-specific data requests with storm mode support."""
164
177
  if not request.datapoint_type:
178
+ # Check for D99 storm trigger (not in DataPointType enum)
179
+ if request.data and request.data.startswith("99"):
180
+ return self._trigger_storm_mode()
165
181
  return None
166
182
 
167
183
  datapoint_type = request.datapoint_type
184
+
185
+ # Storm mode handling
186
+ if datapoint_type == DataPointType.MODULE_ERROR_CODE:
187
+ if self.storm_mode:
188
+ # MODULE_ERROR_CODE query stops storm
189
+ return self._exit_storm_mode()
190
+ else:
191
+ # Normal operation - return error code 00
192
+ return self._build_error_code_response("00")
193
+
194
+ # If in storm mode and not MODULE_ERROR_CODE query, ignore (background thread is sending)
195
+ if self.storm_mode:
196
+ self.logger.debug(
197
+ f"Ignoring query during storm mode for device {self.serial_number}"
198
+ )
199
+ return None # Background thread is sending storm telegrams
200
+
201
+ # Normal data request handling
168
202
  handler = {
169
203
  DataPointType.MODULE_OUTPUT_STATE: self._handle_read_module_output_state,
170
204
  DataPointType.MODULE_STATE: self._handle_read_module_state,
@@ -183,6 +217,9 @@ class XP33ServerService(BaseServerService):
183
217
  )
184
218
  telegram = self._build_response_telegram(data_part)
185
219
 
220
+ # Cache response for potential storm replay
221
+ self.last_response = telegram
222
+
186
223
  self.logger.debug(
187
224
  f"Generated {self.device_type} module type response: {telegram}"
188
225
  )
@@ -230,6 +267,157 @@ class XP33ServerService(BaseServerService):
230
267
  ]
231
268
  return ",".join(levels)
232
269
 
270
+ def _trigger_storm_mode(self) -> Optional[str]:
271
+ """Trigger storm mode via D99 query.
272
+
273
+ Starts a background thread that sends 2 packets per second.
274
+ If storm is already active, this is a no-op.
275
+
276
+ Returns:
277
+ None (no response - storm mode activated).
278
+ """
279
+ # If storm already active, just log and continue
280
+ if self.storm_mode and self.storm_thread and self.storm_thread.is_alive():
281
+ self.logger.debug(
282
+ f"Storm already active for device {self.serial_number}, "
283
+ f"sent {self.storm_packets_sent}/200 packets"
284
+ )
285
+ return None
286
+
287
+ if not self.last_response:
288
+ self.logger.warning(
289
+ f"Cannot trigger storm for device {self.serial_number}: "
290
+ f"no cached response"
291
+ )
292
+ return None
293
+
294
+ self.storm_mode = True
295
+ self.storm_packets_sent = 0
296
+ self.storm_stop_event.clear()
297
+
298
+ # Start background thread to send storm telegrams
299
+ self.storm_thread = threading.Thread(
300
+ target=self._storm_sender_thread,
301
+ daemon=True,
302
+ name=f"Storm-{self.serial_number}",
303
+ )
304
+ self.storm_thread.start()
305
+
306
+ self.logger.info(
307
+ f"Storm triggered via D99 query for device {self.serial_number}"
308
+ )
309
+ return None # No response when entering storm mode
310
+
311
+ def _exit_storm_mode(self) -> str:
312
+ """Exit storm mode and return error code FE.
313
+
314
+ Stops the background storm thread and returns error code.
315
+
316
+ Returns:
317
+ MODULE_ERROR_CODE response with error code FE (buffer overflow).
318
+ """
319
+ self.logger.info(
320
+ f"MODULE_ERROR_CODE query received, stopping storm for device {self.serial_number}"
321
+ )
322
+
323
+ # Signal the storm thread to stop
324
+ self.storm_stop_event.set()
325
+ self.storm_mode = False
326
+
327
+ # Wait for thread to finish (with timeout)
328
+ if self.storm_thread and self.storm_thread.is_alive():
329
+ self.storm_thread.join(timeout=1.0)
330
+
331
+ self.logger.info(
332
+ f"Storm stopped after {self.storm_packets_sent} packets for device {self.serial_number}"
333
+ )
334
+ self.logger.info(
335
+ f"Storm stopped, returning to normal operation for device {self.serial_number}"
336
+ )
337
+ return self._build_error_code_response("FE")
338
+
339
+ def _storm_sender_thread(self) -> None:
340
+ """Background thread that sends storm telegrams continuously.
341
+
342
+ Sends 2 packets per second (500ms delay) until:
343
+ - 200 packets have been sent, or
344
+ - Storm mode is stopped via stop event
345
+
346
+ The storm persists across socket disconnections. If the client disconnects
347
+ and reconnects, the storm will continue on the new connection.
348
+ """
349
+ if not self.last_response:
350
+ self.logger.error(
351
+ f"Storm thread started but missing cached response for {self.serial_number}"
352
+ )
353
+ self.storm_mode = False
354
+ return
355
+
356
+ self.logger.info(
357
+ f"Storm thread started, sending 200 duplicate telegrams at 2 packets/sec for device {self.serial_number}"
358
+ )
359
+
360
+ # Type narrowing for mypy
361
+ cached_response: str = self.last_response
362
+ max_packets = 200
363
+ packets_per_second = 2
364
+ delay_between_packets = 1.0 / packets_per_second # 0.5 seconds
365
+
366
+ try:
367
+ while (
368
+ self.storm_packets_sent < max_packets
369
+ and not self.storm_stop_event.is_set()
370
+ ):
371
+ # Wait for a valid socket (client may have disconnected and reconnected)
372
+ self.add_telegram_buffer(cached_response)
373
+ self.storm_packets_sent += 1
374
+ self.logger.debug(
375
+ f"Storm packet {self.storm_packets_sent}/{max_packets} sent for {self.serial_number}"
376
+ )
377
+
378
+ # Wait before sending next packet (0.5 seconds for 2 packets/sec)
379
+ if self.storm_packets_sent < max_packets:
380
+ self.storm_stop_event.wait(timeout=delay_between_packets)
381
+
382
+ # Log completion status
383
+ if self.storm_packets_sent >= max_packets:
384
+ self.logger.info(
385
+ f"Storm completed: sent all {self.storm_packets_sent} packets for {self.serial_number}"
386
+ )
387
+ elif self.storm_stop_event.is_set():
388
+ self.logger.info(
389
+ f"Storm stopped by error code query: sent {self.storm_packets_sent} packets for {self.serial_number}"
390
+ )
391
+
392
+ # Clean up storm mode
393
+ self.storm_mode = False
394
+
395
+ except Exception as e:
396
+ self.logger.error(
397
+ f"Unexpected error in storm thread for {self.serial_number}: {e}"
398
+ )
399
+ self.storm_mode = False
400
+
401
+ def _build_error_code_response(self, error_code: str) -> str:
402
+ """Build MODULE_ERROR_CODE response telegram.
403
+
404
+ Args:
405
+ error_code: Error code (00 = normal, FE = buffer overflow).
406
+
407
+ Returns:
408
+ The complete MODULE_ERROR_CODE response telegram.
409
+ """
410
+ data_part = (
411
+ f"R{self.serial_number}"
412
+ f"F02D{DataPointType.MODULE_ERROR_CODE.value}"
413
+ f"{error_code}"
414
+ )
415
+ telegram = self._build_response_telegram(data_part)
416
+ self.logger.debug(
417
+ f"Generated {self.device_type} error code response: {telegram}"
418
+ )
419
+ return telegram
420
+
233
421
  def set_channel_dimming(self, channel: int, level: int) -> bool:
234
422
  """Set individual channel dimming level.
235
423