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.
- {conson_xp-1.3.0.dist-info → conson_xp-1.5.0.dist-info}/METADATA +1 -1
- {conson_xp-1.3.0.dist-info → conson_xp-1.5.0.dist-info}/RECORD +19 -19
- xp/__init__.py +1 -1
- xp/models/conbus/conbus_discover.py +19 -3
- xp/models/telegram/system_telegram.py +4 -4
- xp/services/conbus/conbus_discover_service.py +120 -2
- xp/services/conbus/conbus_scan_service.py +1 -1
- xp/services/protocol/telegram_protocol.py +4 -4
- xp/services/server/base_server_service.py +38 -4
- xp/services/server/cp20_server_service.py +2 -1
- xp/services/server/server_service.py +162 -10
- xp/services/server/xp130_server_service.py +2 -1
- xp/services/server/xp20_server_service.py +2 -1
- xp/services/server/xp230_server_service.py +2 -1
- xp/services/server/xp24_server_service.py +123 -50
- xp/services/server/xp33_server_service.py +338 -20
- {conson_xp-1.3.0.dist-info → conson_xp-1.5.0.dist-info}/WHEEL +0 -0
- {conson_xp-1.3.0.dist-info → conson_xp-1.5.0.dist-info}/entry_points.txt +0 -0
- {conson_xp-1.3.0.dist-info → conson_xp-1.5.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
78
|
-
if
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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"
|
|
93
|
-
f"{self.module_type_code}"
|
|
94
|
-
f"{
|
|
214
|
+
f"F02D{datapoint_type.value}"
|
|
215
|
+
f"{self.module_type_code.value:02}"
|
|
216
|
+
f"{data_value}"
|
|
95
217
|
)
|
|
96
|
-
|
|
97
|
-
|
|
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
|
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|