conson-xp 1.3.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.
@@ -5,13 +5,15 @@ 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
 
12
+ from xp.models import ModuleTypeCode
10
13
  from xp.models.telegram.datapoint_type import DataPointType
11
14
  from xp.models.telegram.system_function import SystemFunction
12
15
  from xp.models.telegram.system_telegram import SystemTelegram
13
16
  from xp.services.server.base_server_service import BaseServerService
14
- from xp.utils import calculate_checksum
15
17
 
16
18
 
17
19
  class XP33ServerError(Exception):
@@ -38,27 +40,28 @@ class XP33ServerService(BaseServerService):
38
40
  super().__init__(serial_number)
39
41
  self.variant = variant # XP33 or XP33LR or XP33LED
40
42
  self.device_type = "XP33"
41
- self.module_type_code = 11 # XP33 module type
43
+ self.module_type_code = ModuleTypeCode.XP33 # XP33 module type
42
44
 
43
45
  # XP33 device characteristics (anonymized for interoperability testing)
44
46
  if variant == "XP33LED":
45
47
  self.firmware_version = "XP33LED_V0.00.00"
46
48
  self.ean_code = "1234567890123" # Test EAN - not a real product code
47
49
  self.max_power = 300 # 3 x 100VA
48
- self.module_type_code = 31 # XP33LR module type
50
+ self.module_type_code = ModuleTypeCode.XP33LED # XP33LR module type
49
51
  elif variant == "XP33LR": # XP33LR
50
52
  self.firmware_version = "XP33LR_V0.00.00"
51
53
  self.ean_code = "1234567890124" # Test EAN - not a real product code
52
54
  self.max_power = 640 # Total 640VA
53
- self.module_type_code = 30 # XP33LR module type
55
+ self.module_type_code = ModuleTypeCode.XP33LR # XP33LR module type
54
56
  else: # XP33
55
57
  self.firmware_version = "XP33_V0.04.02"
56
58
  self.ean_code = "1234567890125" # Test EAN - not a real product code
57
59
  self.max_power = 100 # Total 640VA
58
- self.module_type_code = 11 # XP33 module type
60
+ self.module_type_code = ModuleTypeCode.XP33 # XP33 module type
59
61
 
60
62
  self.device_status = "00" # Normal status
61
63
  self.link_number = 4 # 4 links configured
64
+ self.autoreport_status = True
62
65
 
63
66
  # Channel states (3 channels, 0-100% dimming)
64
67
  self.channel_states = [0, 0, 0] # All channels at 0%
@@ -71,36 +74,350 @@ class XP33ServerService(BaseServerService):
71
74
  4: [0, 0, 0], # Scene 4: Off
72
75
  }
73
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
+
88
+ def _handle_device_specific_action_request(
89
+ self, request: SystemTelegram
90
+ ) -> Optional[str]:
91
+ """Handle XP33-specific action requests."""
92
+ telegrams = self._handle_action_channel_dimming(request.data)
93
+ self.logger.debug(f"Generated {self.device_type} action responses: {telegrams}")
94
+ return telegrams
95
+
96
+ def _handle_action_channel_dimming(self, data_value: str) -> str:
97
+ """Handle XP33-specific channel dimming action.
98
+
99
+ Args:
100
+ data_value: Action data in format channel_number:dimming_level.
101
+ E.g., "00:050" means channel 0, 50% dimming.
102
+
103
+ Returns:
104
+ Response telegram(s) - ACK/NAK, optionally with event telegram.
105
+ """
106
+ if ":" not in data_value or len(data_value) < 6:
107
+ return self._build_ack_nak_response_telegram(False)
108
+
109
+ try:
110
+ parts = data_value.split(":")
111
+ channel_number = int(parts[0])
112
+ dimming_level = int(parts[1])
113
+ except (ValueError, IndexError):
114
+ return self._build_ack_nak_response_telegram(False)
115
+
116
+ if channel_number not in range(len(self.channel_states)):
117
+ return self._build_ack_nak_response_telegram(False)
118
+
119
+ if dimming_level not in range(0, 101):
120
+ return self._build_ack_nak_response_telegram(False)
121
+
122
+ previous_level = self.channel_states[channel_number]
123
+ self.channel_states[channel_number] = dimming_level
124
+ state_changed = (previous_level == 0 and dimming_level > 0) or (
125
+ previous_level > 0 and dimming_level == 0
126
+ )
127
+
128
+ telegrams = self._build_ack_nak_response_telegram(True)
129
+ if state_changed and self.autoreport_status:
130
+ # Report dimming change event
131
+ telegrams += self._build_dimming_event_telegram(
132
+ dimming_level, channel_number
133
+ )
134
+
135
+ return telegrams
136
+
137
+ def _build_ack_nak_response_telegram(self, ack_or_nak: bool) -> str:
138
+ """Build a complete ACK or NAK response telegram with checksum.
139
+
140
+ Args:
141
+ ack_or_nak: true: ACK telegram response, false: NAK telegram response.
142
+
143
+ Returns:
144
+ The complete telegram with checksum enclosed in angle brackets.
145
+ """
146
+ data_value = (
147
+ SystemFunction.ACK.value if ack_or_nak else SystemFunction.NAK.value
148
+ )
149
+ data_part = f"R{self.serial_number}" f"F{data_value:02}D"
150
+ return self._build_response_telegram(data_part)
151
+
152
+ def _build_dimming_event_telegram(
153
+ self, dimming_level: int, channel_number: int
154
+ ) -> str:
155
+ """Build a complete dimming event telegram with checksum.
156
+
157
+ Args:
158
+ dimming_level: Dimming level 0-100%.
159
+ channel_number: Channel concerned (0-2).
160
+
161
+ Returns:
162
+ The complete event telegram with checksum enclosed in angle brackets.
163
+ """
164
+ data_value = "M" if dimming_level > 0 else "B"
165
+ data_part = (
166
+ f"E{self.module_type_code.value:02}"
167
+ f"L{self.link_number:02}"
168
+ f"I{channel_number:02}"
169
+ f"{data_value}"
170
+ )
171
+ return self._build_response_telegram(data_part)
172
+
74
173
  def _handle_device_specific_data_request(
75
174
  self, request: SystemTelegram
76
175
  ) -> Optional[str]:
77
- """Handle XP24-specific data requests."""
78
- if (
79
- request.system_function != SystemFunction.READ_DATAPOINT
80
- or not request.datapoint_type
81
- ):
176
+ """Handle XP33-specific data requests with storm mode support."""
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()
82
181
  return None
83
182
 
84
183
  datapoint_type = request.datapoint_type
85
- datapoint_values = {
86
- DataPointType.MODULE_OUTPUT_STATE: "xxxxx001",
87
- DataPointType.MODULE_STATE: "OFF",
88
- DataPointType.MODULE_OPERATING_HOURS: "00:000[H],01:000[H],02:000[H]",
89
- }
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
202
+ handler = {
203
+ DataPointType.MODULE_OUTPUT_STATE: self._handle_read_module_output_state,
204
+ DataPointType.MODULE_STATE: self._handle_read_module_state,
205
+ DataPointType.MODULE_OPERATING_HOURS: self._handle_read_module_operating_hours,
206
+ DataPointType.MODULE_LIGHT_LEVEL: self._handle_read_light_level,
207
+ }.get(datapoint_type)
208
+ if not handler:
209
+ return None
210
+
211
+ data_value = handler()
90
212
  data_part = (
91
213
  f"R{self.serial_number}"
92
- f"F02{datapoint_type.value}"
93
- f"{self.module_type_code}"
94
- f"{datapoint_values.get(datapoint_type)}"
214
+ f"F02D{datapoint_type.value}"
215
+ f"{self.module_type_code.value:02}"
216
+ f"{data_value}"
95
217
  )
96
- checksum = calculate_checksum(data_part)
97
- telegram = f"<{data_part}{checksum}>"
218
+ telegram = self._build_response_telegram(data_part)
219
+
220
+ # Cache response for potential storm replay
221
+ self.last_response = telegram
98
222
 
99
223
  self.logger.debug(
100
224
  f"Generated {self.device_type} module type response: {telegram}"
101
225
  )
102
226
  return telegram
103
227
 
228
+ def _handle_read_module_output_state(self) -> str:
229
+ """Handle XP33-specific module output state.
230
+
231
+ Returns:
232
+ String representation of the output state for 3 channels.
233
+ """
234
+ return (
235
+ f"xxxxx"
236
+ f"{1 if self.channel_states[0] > 0 else 0}"
237
+ f"{1 if self.channel_states[1] > 0 else 0}"
238
+ f"{1 if self.channel_states[2] > 0 else 0}"
239
+ )
240
+
241
+ def _handle_read_module_state(self) -> str:
242
+ """Handle XP33-specific module state.
243
+
244
+ Returns:
245
+ 'ON' if any channel is active, 'OFF' otherwise.
246
+ """
247
+ if any(level > 0 for level in self.channel_states):
248
+ return "ON"
249
+ return "OFF"
250
+
251
+ def _handle_read_module_operating_hours(self) -> str:
252
+ """Handle XP33-specific module operating hours.
253
+
254
+ Returns:
255
+ Operating hours for all 3 channels.
256
+ """
257
+ return "00:000[H],01:000[H],02:000[H]"
258
+
259
+ def _handle_read_light_level(self) -> str:
260
+ """Handle XP33-specific light level reading.
261
+
262
+ Returns:
263
+ Light levels for all channels in format "00:000[%],01:000[%],02:000[%]".
264
+ """
265
+ levels = [
266
+ f"{i:02d}:{level:03d}[%]" for i, level in enumerate(self.channel_states)
267
+ ]
268
+ return ",".join(levels)
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
+
104
421
  def set_channel_dimming(self, channel: int, level: int) -> bool:
105
422
  """Set individual channel dimming level.
106
423
 
@@ -147,6 +464,7 @@ class XP33ServerService(BaseServerService):
147
464
  "max_power": self.max_power,
148
465
  "status": self.device_status,
149
466
  "link_number": self.link_number,
467
+ "autoreport_status": self.autoreport_status,
150
468
  "channel_states": self.channel_states.copy(),
151
469
  "available_scenes": list(self.scenes.keys()),
152
470
  }