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.
@@ -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:
@@ -253,15 +289,86 @@ class ServerService:
253
289
  self.logger.error(f"Error closing client socket: {e}")
254
290
 
255
291
  def _process_request(self, message: str) -> List[str]:
256
- """Process incoming request and generate responses."""
292
+ """Process incoming request and generate responses.
293
+
294
+ Args:
295
+ message: Message potentially containing multiple telegrams in format <TELEGRAM><TELEGRAM2>...
296
+
297
+ Returns:
298
+ List of responses for all processed telegrams.
299
+ """
300
+ responses: list[str] = []
301
+
302
+ try:
303
+ # Split message into individual telegrams (enclosed in angle brackets)
304
+ telegrams = self._split_telegrams(message)
305
+
306
+ if not telegrams:
307
+ self.logger.warning(f"No valid telegrams found in message: {message}")
308
+ return responses
309
+
310
+ # Process each telegram
311
+ for telegram in telegrams:
312
+ telegram_responses = self._process_single_telegram(telegram)
313
+ responses.extend(telegram_responses)
314
+
315
+ except Exception as e:
316
+ self.logger.error(f"Error processing request: {e}")
317
+
318
+ return responses
319
+
320
+ def _split_telegrams(self, message: str) -> List[str]:
321
+ """Split message into individual telegrams.
322
+
323
+ Args:
324
+ message: Raw message containing one or more telegrams in format <TELEGRAM><TELEGRAM2>...
325
+
326
+ Returns:
327
+ List of individual telegram strings including angle brackets.
328
+ """
329
+ telegrams = []
330
+ start = 0
331
+
332
+ while True:
333
+ # Find the start of a telegram
334
+ start_idx = message.find("<", start)
335
+ if start_idx == -1:
336
+ break
337
+
338
+ # Find the end of the telegram
339
+ end_idx = message.find(">", start_idx)
340
+ if end_idx == -1:
341
+ self.logger.warning(
342
+ f"Incomplete telegram found starting at position {start_idx}"
343
+ )
344
+ break
345
+
346
+ # Extract telegram including angle brackets
347
+ telegram = message[start_idx : end_idx + 1]
348
+ telegrams.append(telegram)
349
+
350
+ # Move to the next position
351
+ start = end_idx + 1
352
+
353
+ return telegrams
354
+
355
+ def _process_single_telegram(self, telegram: str) -> List[str]:
356
+ """Process a single telegram and generate responses.
357
+
358
+ Args:
359
+ telegram: A single telegram string.
360
+
361
+ Returns:
362
+ List of response strings for this telegram.
363
+ """
257
364
  responses: list[str] = []
258
365
 
259
366
  try:
260
367
  # Parse the telegram
261
- parsed_telegram = self.telegram_service.parse_system_telegram(message)
368
+ parsed_telegram = self.telegram_service.parse_system_telegram(telegram)
262
369
 
263
370
  if not parsed_telegram:
264
- self.logger.warning(f"Failed to parse telegram: {message}")
371
+ self.logger.warning(f"Failed to parse telegram: {telegram}")
265
372
  return responses
266
373
 
267
374
  # Handle discover requests
@@ -296,7 +403,7 @@ class ServerService:
296
403
  )
297
404
 
298
405
  except Exception as e:
299
- self.logger.error(f"Error processing request: {e}")
406
+ self.logger.error(f"Error processing telegram: {e}")
300
407
 
301
408
  return responses
302
409
 
@@ -319,3 +426,48 @@ class ServerService:
319
426
  self.logger.info(
320
427
  f"Configuration reloaded: {len(self.devices)} devices, {len(self.device_services)} services"
321
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)
@@ -7,6 +7,7 @@ XP130 is an Ethernet/TCPIP interface module.
7
7
 
8
8
  from typing import Dict
9
9
 
10
+ from xp.models import ModuleTypeCode
10
11
  from xp.services.server.base_server_service import BaseServerService
11
12
 
12
13
 
@@ -32,7 +33,7 @@ class XP130ServerService(BaseServerService):
32
33
  """
33
34
  super().__init__(serial_number)
34
35
  self.device_type = "XP130"
35
- self.module_type_code = 13 # XP130 module type from registry
36
+ self.module_type_code = ModuleTypeCode.XP130 # XP130 module type from registry
36
37
  self.firmware_version = "XP130_V1.02.15"
37
38
 
38
39
  # XP130-specific network configuration
@@ -6,6 +6,7 @@ including response generation and device configuration handling.
6
6
 
7
7
  from typing import Dict
8
8
 
9
+ from xp.models import ModuleTypeCode
9
10
  from xp.services.server.base_server_service import BaseServerService
10
11
 
11
12
 
@@ -31,7 +32,7 @@ class XP20ServerService(BaseServerService):
31
32
  """
32
33
  super().__init__(serial_number)
33
34
  self.device_type = "XP20"
34
- self.module_type_code = 33 # XP20 module type from registry
35
+ self.module_type_code = ModuleTypeCode.XP20 # XP20 module type from registry
35
36
  self.firmware_version = "XP20_V0.01.05"
36
37
 
37
38
  def get_device_info(self) -> Dict:
@@ -6,6 +6,7 @@ including response generation and device configuration handling.
6
6
 
7
7
  from typing import Dict
8
8
 
9
+ from xp.models import ModuleTypeCode
9
10
  from xp.services.server.base_server_service import BaseServerService
10
11
 
11
12
 
@@ -31,7 +32,7 @@ class XP230ServerService(BaseServerService):
31
32
  """
32
33
  super().__init__(serial_number)
33
34
  self.device_type = "XP230"
34
- self.module_type_code = 34 # XP230 module type from registry
35
+ self.module_type_code = ModuleTypeCode.XP230 # XP230 module type from registry
35
36
  self.firmware_version = "XP230_V1.00.04"
36
37
 
37
38
  def get_device_info(self) -> Dict:
@@ -6,6 +6,7 @@ including response generation and device configuration handling.
6
6
 
7
7
  from typing import Dict, Optional
8
8
 
9
+ from xp.models import ModuleTypeCode
9
10
  from xp.models.telegram.datapoint_type import DataPointType
10
11
  from xp.models.telegram.system_function import SystemFunction
11
12
  from xp.models.telegram.system_telegram import SystemTelegram
@@ -18,6 +19,16 @@ class XP24ServerError(Exception):
18
19
  pass
19
20
 
20
21
 
22
+ class XP24Output:
23
+ """Represents an XP24 output state.
24
+
25
+ Attributes:
26
+ state: Current state of the output (True=on, False=off).
27
+ """
28
+
29
+ state: bool = False
30
+
31
+
21
32
  class XP24ServerService(BaseServerService):
22
33
  """
23
34
  XP24 device emulation service.
@@ -34,30 +45,107 @@ class XP24ServerService(BaseServerService):
34
45
  """
35
46
  super().__init__(serial_number)
36
47
  self.device_type = "XP24"
37
- self.module_type_code = 7 # XP24 module type from registry
48
+ self.module_type_code = ModuleTypeCode.XP24
49
+ self.autoreport_status = True
38
50
  self.firmware_version = "XP24_V0.34.03"
51
+ self.output_0: XP24Output = XP24Output()
52
+ self.output_1: XP24Output = XP24Output()
53
+ self.output_2: XP24Output = XP24Output()
54
+ self.output_3: XP24Output = XP24Output()
55
+
56
+ def _handle_device_specific_action_request(
57
+ self, request: SystemTelegram
58
+ ) -> Optional[str]:
59
+ """Handle XP24-specific data requests."""
60
+ telegrams = self._handle_action_module_output_state(request.data)
61
+ self.logger.debug(
62
+ f"Generated {self.device_type} module type responses: {telegrams}"
63
+ )
64
+ return telegrams
65
+
66
+ def _handle_action_module_output_state(self, data_value: str) -> str:
67
+ """Handle XP24-specific module output state."""
68
+ output_number = int(data_value[:2])
69
+ output_state = data_value[2:]
70
+ if output_number not in range(0, 4):
71
+ return self._build_ack_nak_response_telegram(False)
72
+
73
+ if output_state not in ("AA", "AB"):
74
+ return self._build_ack_nak_response_telegram(False)
75
+
76
+ output = (self.output_0, self.output_1, self.output_2, self.output_3)[
77
+ output_number
78
+ ]
79
+ previous_state = output.state
80
+ output.state = True if output_state == "AB" else False
81
+ state_changed = previous_state != output.state
82
+
83
+ telegrams = self._build_ack_nak_response_telegram(True)
84
+ if state_changed and self.autoreport_status:
85
+ telegrams += self._build_make_break_response_telegram(
86
+ output.state, output_number
87
+ )
88
+
89
+ return telegrams
90
+
91
+ def _build_ack_nak_response_telegram(self, ack_or_nak: bool) -> str:
92
+ """Build a complete ACK or NAK response telegram with checksum.
93
+
94
+ Args:
95
+ ack_or_nak: true: ACK telegram response, false: NAK telegram response.
96
+
97
+ Returns:
98
+ The complete telegram with checksum enclosed in angle brackets.
99
+ """
100
+ data_value = (
101
+ SystemFunction.ACK.value if ack_or_nak else SystemFunction.NAK.value
102
+ )
103
+ data_part = f"R{self.serial_number}" f"F{data_value:02}D"
104
+ return self._build_response_telegram(data_part)
105
+
106
+ def _build_make_break_response_telegram(
107
+ self, make_or_break: bool, output_number: int
108
+ ) -> str:
109
+ """Build a complete ACK or NAK response telegram with checksum.
110
+
111
+ Args:
112
+ make_or_break: true: MAKE event response, false: BREAK event response.
113
+ output_number: output concerned
114
+
115
+ Returns:
116
+ The complete event telegram with checksum enclosed in angle brackets.
117
+ """
118
+ data_value = "M" if make_or_break else "B"
119
+ data_part = (
120
+ f"E{self.module_type_code.value:02}"
121
+ f"L{self.link_number:02}"
122
+ f"I{output_number:02}"
123
+ f"{data_value}"
124
+ )
125
+ return self._build_response_telegram(data_part)
39
126
 
40
127
  def _handle_device_specific_data_request(
41
128
  self, request: SystemTelegram
42
129
  ) -> Optional[str]:
43
130
  """Handle XP24-specific data requests."""
44
- if (
45
- request.system_function != SystemFunction.READ_DATAPOINT
46
- or not request.datapoint_type
47
- ):
131
+ if not request.datapoint_type:
48
132
  return None
49
133
 
50
134
  datapoint_type = request.datapoint_type
51
- datapoint_values = {
52
- DataPointType.MODULE_OUTPUT_STATE: "xxxx0001",
53
- DataPointType.MODULE_STATE: "OFF",
54
- DataPointType.MODULE_OPERATING_HOURS: "00:000[H],01:000[H],02:000[H],03:000[H]",
55
- }
135
+ handler = {
136
+ DataPointType.MODULE_OUTPUT_STATE: self._handle_read_module_output_state,
137
+ DataPointType.MODULE_STATE: self._handle_read_module_state,
138
+ DataPointType.MODULE_OPERATING_HOURS: self._handle_read_module_operating_hours,
139
+ }.get(datapoint_type)
140
+ if not handler:
141
+ return None
142
+
143
+ data_value = handler()
56
144
  data_part = (
57
145
  f"R{self.serial_number}"
58
- f"F02{datapoint_type.value}"
59
- f"{self.module_type_code}"
60
- f"{datapoint_values.get(datapoint_type)}"
146
+ f"F02D{datapoint_type.value}"
147
+ f"{self.module_type_code.value:02}"
148
+ f"{data_value}"
61
149
  )
62
150
  telegram = self._build_response_telegram(data_part)
63
151
 
@@ -66,21 +154,26 @@ class XP24ServerService(BaseServerService):
66
154
  )
67
155
  return telegram
68
156
 
69
- def _handle_device_specific_action_request(
70
- self, request: SystemTelegram
71
- ) -> Optional[str]:
72
- """Handle XP24-specific action requests.
73
-
74
- Args:
75
- request: The system telegram request.
76
-
77
- Returns:
78
- The response telegram string, or None if request cannot be handled.
79
- """
80
- if request.system_function != SystemFunction.ACTION:
81
- return None
82
-
83
- return self.generate_action_response(request)
157
+ def _handle_read_module_operating_hours(self) -> str:
158
+ """Handle XP24-specific module operating hours."""
159
+ return "00:000[H],01:000[H],02:000[H],03:000[H]"
160
+
161
+ def _handle_read_module_state(self) -> str:
162
+ """Handle XP24-specific module state."""
163
+ for output in (self.output_0, self.output_1, self.output_2, self.output_3):
164
+ if output.state:
165
+ return "ON"
166
+ return "OFF"
167
+
168
+ def _handle_read_module_output_state(self) -> str:
169
+ """Handle XP24-specific module output state."""
170
+ return (
171
+ f"xxxx"
172
+ f"{1 if self.output_0.state else 0}"
173
+ f"{1 if self.output_1.state else 0}"
174
+ f"{1 if self.output_2.state else 0}"
175
+ f"{1 if self.output_3.state else 0}"
176
+ )
84
177
 
85
178
  def get_device_info(self) -> Dict:
86
179
  """Get XP24 device information.
@@ -91,29 +184,9 @@ class XP24ServerService(BaseServerService):
91
184
  return {
92
185
  "serial_number": self.serial_number,
93
186
  "device_type": self.device_type,
187
+ "module_type_code": self.module_type_code.value,
94
188
  "firmware_version": self.firmware_version,
95
189
  "status": self.device_status,
96
190
  "link_number": self.link_number,
191
+ "autoreport_status": self.autoreport_status,
97
192
  }
98
-
99
- def generate_action_response(self, request: SystemTelegram) -> Optional[str]:
100
- """Generate action response telegram (simulated).
101
-
102
- Args:
103
- request: The system telegram request.
104
-
105
- Returns:
106
- The ACK or NAK response telegram string.
107
- """
108
- response = "F19D" # NAK
109
- if (
110
- request.system_function == SystemFunction.ACTION
111
- and request.data[:2] in ("00", "01", "02", "03")
112
- and request.data[2:] in ("AA", "AB")
113
- ):
114
- response = "F18D" # ACK
115
-
116
- data_part = f"R{self.serial_number}{response}"
117
- telegram = self._build_response_telegram(data_part)
118
- self._log_response("module_action_response", telegram)
119
- return telegram