conson-xp 1.41.0__py3-none-any.whl → 1.44.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.41.0
3
+ Version: 1.44.0
4
4
  Summary: XP Protocol Communication Tools
5
5
  Author-Email: ldvchosal <ldvchosal@github.com>
6
6
  License: MIT License
@@ -49,6 +49,7 @@ Requires-Dist: twisted>=25.5.0
49
49
  Requires-Dist: bubus>=1.5.6
50
50
  Requires-Dist: psygnal>=0.15.0
51
51
  Requires-Dist: textual>=1.0.0
52
+ Requires-Dist: python-statemachine>=2.5.0
52
53
  Description-Content-Type: text/markdown
53
54
 
54
55
  # 🔌 XP Protocol Communication Tool
@@ -1,14 +1,14 @@
1
- conson_xp-1.41.0.dist-info/METADATA,sha256=pML1FepvoeR1ktngrS69ieWa5rMO0j43m8BC22G_UW0,11361
2
- conson_xp-1.41.0.dist-info/WHEEL,sha256=tsUv_t7BDeJeRHaSrczbGeuK-TtDpGsWi_JfpzD255I,90
3
- conson_xp-1.41.0.dist-info/entry_points.txt,sha256=1OcdIcDM1hz3ljCXgybaPUh1IOFEwkaTgLIW9u9zGeg,50
4
- conson_xp-1.41.0.dist-info/licenses/LICENSE,sha256=rxj6woMM-r3YCyGq_UHFtbh7kHTAJgrccH6O-33IDE4,1419
5
- xp/__init__.py,sha256=fIc-tmd56J8tWGwB8NrHfsUhz6rBuPHOZmnmWfimedo,181
1
+ conson_xp-1.44.0.dist-info/METADATA,sha256=_XnV63YzozzZlbnPRlvU5adBIY6s7pZq0yZR6xNaVNc,11403
2
+ conson_xp-1.44.0.dist-info/WHEEL,sha256=tsUv_t7BDeJeRHaSrczbGeuK-TtDpGsWi_JfpzD255I,90
3
+ conson_xp-1.44.0.dist-info/entry_points.txt,sha256=1OcdIcDM1hz3ljCXgybaPUh1IOFEwkaTgLIW9u9zGeg,50
4
+ conson_xp-1.44.0.dist-info/licenses/LICENSE,sha256=rxj6woMM-r3YCyGq_UHFtbh7kHTAJgrccH6O-33IDE4,1419
5
+ xp/__init__.py,sha256=_M4wR8pgjr7q-i0BO0eRQ8Tx1cbV_kmviwFhqlqRnYQ,181
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=G7A1KFRSV0CEeDTqr_khu-K9_sc01CTI2KSfkFcaBRM,4949
9
9
  xp/cli/commands/conbus/__init__.py,sha256=HYaX2__XxwD3Xaw4CzflvL8CwoUa4yR6wOyH8wwyofM,535
10
10
  xp/cli/commands/conbus/conbus.py,sha256=1j9-Nwnf6nqD4ztBZnZEbJOTlkYo4D-Ks2J7BGNF3MU,3575
11
- xp/cli/commands/conbus/conbus_actiontable_commands.py,sha256=fIEe45iMWZ3Dbr0STzO8qbtWHR-R8qpGa_sLNeBm_og,7471
11
+ xp/cli/commands/conbus/conbus_actiontable_commands.py,sha256=yLVSADwei7tX6JFa25TCpTxyMHyNJfWlDFqpR8AGRmo,7657
12
12
  xp/cli/commands/conbus/conbus_autoreport_commands.py,sha256=TgXFVpoyFNiEatL6r78IghzyF0R2S3cgTrGZaiJPjwA,3934
13
13
  xp/cli/commands/conbus/conbus_blink_commands.py,sha256=HRn4Lr_BO7_WynsaUnO_hKezOi3MVhkPYEOnh0rMMlg,5324
14
14
  xp/cli/commands/conbus/conbus_config_commands.py,sha256=BugIbgNX6_s4MySGvI6tWZkwguciajHUX2Xz8XBux7k,716
@@ -121,7 +121,7 @@ xp/services/actiontable/msactiontable_xp24_serializer.py,sha256=ku84HCCjYDM9XpRK
121
121
  xp/services/actiontable/msactiontable_xp33_serializer.py,sha256=MFjEwSsIa8l3RJt-ig828E6kyiFYYXMHFKe4Q0A3NvA,8781
122
122
  xp/services/conbus/__init__.py,sha256=Hi35sMKu9o6LpYoi2tQDaQoMb8M5sOt_-LUTxxaCU_0,28
123
123
  xp/services/conbus/actiontable/__init__.py,sha256=oD6vRk_Ye-eZ9s_hldAgtRJFu4mfAnODqpkJUGHHszk,40
124
- xp/services/conbus/actiontable/actiontable_download_service.py,sha256=C6cjNRRsl7_jjn94I6ycCDvoqIpivNv0cMVkR-CQBXk,7608
124
+ xp/services/conbus/actiontable/actiontable_download_service.py,sha256=RMGC3WxlMQDJkkXy3oKmqkORrXNwQyjDkyfEGWX3JJE,21294
125
125
  xp/services/conbus/actiontable/actiontable_list_service.py,sha256=BYNpxdhdVvn5Z3XXvUYyiGGZ3fOoKR1F0boYDfAQ80c,2894
126
126
  xp/services/conbus/actiontable/actiontable_show_service.py,sha256=Z11bVYstnIhahFKTL8WqgTmK8rMK6kvismdy7679ojY,3013
127
127
  xp/services/conbus/actiontable/actiontable_upload_service.py,sha256=HTWJqJTIDFsp5v0SBx1Wt5gUeso6w03N6bKbtsbHtJY,9705
@@ -161,7 +161,7 @@ xp/services/homekit/homekit_service.py,sha256=0lW-hg40ETco3gDBEYkR_sX-UIYsLSKCD4
161
161
  xp/services/log_file_service.py,sha256=fvPcZQj8XOKUA-4ep5R8n0PelvwvRlTLlVxvIWM5KR4,10506
162
162
  xp/services/module_type_service.py,sha256=xWhr1EAZMykL5uNWHWdpa5T8yNruGKH43XRTOS8GwZg,7477
163
163
  xp/services/protocol/__init__.py,sha256=qRufBmqRKGzpuzZ5bxBbmwf510TT00Ke8s5HcWGnqRY,818
164
- xp/services/protocol/conbus_event_protocol.py,sha256=PpSr5sWcibN_xb2iMWAtWc0dTkUkFZelgYuPiejutkE,15155
164
+ xp/services/protocol/conbus_event_protocol.py,sha256=XjdVwy7P87Utu_UqK7upfugrA5fYG2iWBwC4go9KFLY,15498
165
165
  xp/services/protocol/conbus_protocol.py,sha256=JO7yLkD_ohPT0ETjnAIx4CGpZyobf4LdbuozM_43btE,10276
166
166
  xp/services/protocol/protocol_factory.py,sha256=PmjN9AtW9sxNo3voqUiNgQA-pTvX1RW4XXFlHKfFr5Q,2470
167
167
  xp/services/protocol/telegram_protocol.py,sha256=Ki5DrXsKxiaqLcdP9WWUuhUI7cPu2DfwyZkh-Gv9Lb8,9496
@@ -171,7 +171,7 @@ xp/services/server/base_server_service.py,sha256=B-ntxp3swbwuri-9_2EuvBDi-4Uo9AH
171
171
  xp/services/server/client_buffer_manager.py,sha256=1d_MqfzuUqBwaQUiC1n5K76WwSxrdngYAmNH7he6u3o,2235
172
172
  xp/services/server/cp20_server_service.py,sha256=SXdI6Jt400T9sLdw86ovEqKRGeV3nYVaHEA9Gcj6W2A,2041
173
173
  xp/services/server/device_service_factory.py,sha256=Y4TvSFALeq0zYzHfCwcbikSpmIyYbLcvm9756n5Jm7Q,3744
174
- xp/services/server/server_service.py,sha256=YAkn3rxNYnxs3E1Dpwtt3ZzymFXZHKEz8Nitim-Xewg,16300
174
+ xp/services/server/server_service.py,sha256=2jMrL-Azn7KaqpgSI1ztBh0UD3JWom5ns33IkqMCDac,16302
175
175
  xp/services/server/xp130_server_service.py,sha256=YnvetDp72-QzkyDGB4qfZZIwFs03HuibUOz2zb9XR0c,2191
176
176
  xp/services/server/xp20_server_service.py,sha256=1wJ7A-bRkN9O5Spu3q3LNDW31mNtNF2eNMQ5E6O2ltA,2928
177
177
  xp/services/server/xp230_server_service.py,sha256=k9ftCY5tjLFP31mKVCspq283RVaPkGx-Yq61Urk8JLs,1815
@@ -207,4 +207,4 @@ xp/utils/logging.py,sha256=rZDXwlBrYK8A6MPq5StsMNpgsRowzJXM6fvROPwJdGM,3750
207
207
  xp/utils/serialization.py,sha256=RWHHk86feaB4ZP7rjE4qOWK0900yg2joUBDkP76gfOY,4618
208
208
  xp/utils/state_machine.py,sha256=Oe2sLwCh9z_vr1tF6X0ZRGTeuckRQAGzmef7xc9CNdc,2413
209
209
  xp/utils/time_utils.py,sha256=dEyViDlAG9GWU-J3D_YVa-sGma6yiyyMTgN4h2x3PY4,3781
210
- conson_xp-1.41.0.dist-info/RECORD,,
210
+ conson_xp-1.44.0.dist-info/RECORD,,
xp/__init__.py CHANGED
@@ -3,7 +3,7 @@
3
3
  conson-xp package.
4
4
  """
5
5
 
6
- __version__ = "1.41.0"
6
+ __version__ = "1.44.0"
7
7
  __manufacturer__ = "salchichon"
8
8
  __model__ = "xp.cli"
9
9
  __serial__ = "2025.09.23.000"
@@ -61,7 +61,7 @@ def conbus_download_actiontable(ctx: Context, serial_number: str) -> None:
61
61
  """
62
62
  click.echo(progress, nl=False)
63
63
 
64
- def on_finish(
64
+ def on_actiontable_received(
65
65
  _actiontable: ActionTable,
66
66
  actiontable_dict: Dict[str, Any],
67
67
  actiontable_short: list[str],
@@ -79,6 +79,9 @@ def conbus_download_actiontable(ctx: Context, serial_number: str) -> None:
79
79
  "actiontable": actiontable_dict,
80
80
  }
81
81
  click.echo(json.dumps(output, indent=2, default=str))
82
+
83
+ def on_finish() -> None:
84
+ """Handle successful completion of action table download."""
82
85
  service.stop_reactor()
83
86
 
84
87
  def on_error(error: str) -> None:
@@ -93,6 +96,7 @@ def conbus_download_actiontable(ctx: Context, serial_number: str) -> None:
93
96
  with service:
94
97
  service.on_progress.connect(on_progress)
95
98
  service.on_finish.connect(on_finish)
99
+ service.on_actiontable_received.connect(on_actiontable_received)
96
100
  service.on_error.connect(on_error)
97
101
  service.start(serial_number=serial_number)
98
102
  service.start_reactor()
@@ -2,36 +2,146 @@
2
2
 
3
3
  import logging
4
4
  from dataclasses import asdict
5
- from typing import Any, Dict, Optional
5
+ from enum import Enum
6
+ from typing import Any, Optional
6
7
 
7
- from psygnal import Signal
8
+ from psygnal import SignalInstance
9
+ from statemachine import State, StateMachine
8
10
 
9
11
  from xp.models.actiontable.actiontable import ActionTable
10
12
  from xp.models.protocol.conbus_protocol import TelegramReceivedEvent
13
+ from xp.models.telegram.datapoint_type import DataPointType
14
+ from xp.models.telegram.reply_telegram import ReplyTelegram
11
15
  from xp.models.telegram.system_function import SystemFunction
12
16
  from xp.models.telegram.telegram_type import TelegramType
13
17
  from xp.services.actiontable.actiontable_serializer import ActionTableSerializer
14
18
  from xp.services.protocol.conbus_event_protocol import ConbusEventProtocol
15
19
  from xp.services.telegram.telegram_service import TelegramService
16
20
 
21
+ # Constants
22
+ NO_ERROR_CODE = "00"
23
+ CHUNK_HEADER_LENGTH = 2 # data_value format: 2-char counter + actiontable chunk
24
+ MAX_ERROR_RETRIES = 3 # Max retries for error_status_received before giving up
17
25
 
18
- class ActionTableDownloadService:
19
- """TCP client service for downloading action tables from Conbus modules.
20
26
 
21
- Manages TCP socket connections, handles telegram generation and transmission,
22
- and processes server responses for action table downloads.
27
+ class Phase(Enum):
28
+ """Download workflow phases.
29
+
30
+ The download workflow consists of three sequential phases:
31
+ - INIT: Drain pending telegrams, query error status → proceed to DOWNLOAD
32
+ - DOWNLOAD: Request actiontable, receive chunks with ACK, until EOF
33
+ - CLEANUP: Drain pending telegrams, query error status → proceed to COMPLETED
23
34
 
24
35
  Attributes:
25
- on_progress: Signal emitted with telegram frame when progress is made.
26
- on_error: Signal emitted with error message string when an error occurs.
27
- on_finish: Signal emitted with (ActionTable, Dict[str, Any], list[str]) when complete.
36
+ INIT: Initial phase - drain pending telegrams and query error status.
37
+ DOWNLOAD: Download phase - request actiontable and receive chunks.
38
+ CLEANUP: Cleanup phase - drain remaining telegrams and verify status.
28
39
  """
29
40
 
30
- on_progress: Signal = Signal(str)
31
- on_error: Signal = Signal(str)
32
- on_finish: Signal = Signal(
33
- ActionTable, Dict[str, Any], list[str]
34
- ) # (ActionTable, Dict[str, Any], list[str])
41
+ INIT = "init"
42
+ DOWNLOAD = "download"
43
+ CLEANUP = "cleanup"
44
+
45
+
46
+ class ActionTableDownloadService(StateMachine):
47
+ """Service for downloading action tables from Conbus modules via TCP.
48
+
49
+ Inherits from StateMachine - the service IS the state machine. Uses guard
50
+ conditions to share states between INIT and CLEANUP phases.
51
+ see: Download-ActionTable-Workflow.dot
52
+
53
+ States (9 total):
54
+ idle -> receiving -> resetting -> waiting_ok -> requesting
55
+ -> waiting_data <-> receiving_chunk -> processing_eof -> completed
56
+
57
+ Phases - INIT and CLEANUP share the same states (receiving, resetting, waiting_ok):
58
+
59
+ INIT phase (drain → reset → wait_ok):
60
+ idle -> receiving -> resetting -> waiting_ok --(guard: is_init_phase)--> requesting
61
+
62
+ DOWNLOAD phase (request → receive chunks → EOF):
63
+ requesting -> waiting_data <-> receiving_chunk -> processing_eof
64
+
65
+ CLEANUP phase (drain → reset → wait_ok):
66
+ processing_eof -> receiving -> resetting -> waiting_ok --(guard: is_cleanup_phase)--> completed
67
+
68
+ The drain/reset/wait_ok cycle:
69
+ 1. Drain pending telegrams (receiving state discards telegram without reading them).
70
+ There may be a lot of telegram. We are listening and receiving until no more
71
+ telegram arrive and timeout occurs.
72
+ 2. Timeout triggers error status query (resetting)
73
+ 3. Wait for response (waiting_ok)
74
+ 4. On no error: guard determines target (requesting or completed)
75
+ On error: retry from drain step
76
+
77
+ Attributes:
78
+ on_progress: Signal emitted with "." for each chunk received.
79
+ on_error: Signal emitted with error message string.
80
+ on_actiontable_received: Signal emitted with (ActionTable, dict, list).
81
+ on_finish: Signal emitted when download and cleanup completed.
82
+ idle: Initial state - waiting for connection.
83
+ receiving: Drain telegrams state (INIT or CLEANUP phase).
84
+ resetting: Query error status state.
85
+ waiting_ok: Await error status response state.
86
+ requesting: DOWNLOAD phase - send download request state.
87
+ waiting_data: DOWNLOAD phase - await chunks state.
88
+ receiving_chunk: DOWNLOAD phase - process chunk state.
89
+ processing_eof: DOWNLOAD phase - deserialize result state.
90
+ completed: Final state - download finished.
91
+ do_connect: Transition from idle to receiving.
92
+ filter_telegram: Self-transition in receiving to drain telegrams.
93
+ do_timeout: Transition on timeout events.
94
+ send_error_status: Transition from resetting to waiting_ok.
95
+ error_status_received: Transition when error status is received.
96
+ no_error_status_received: Transition when no error status received.
97
+ send_download: Transition from requesting to waiting_data.
98
+ receive_chunk: Transition from waiting_data to receiving_chunk.
99
+ send_ack: Transition from receiving_chunk to waiting_data.
100
+ receive_eof: Transition from waiting_data to processing_eof.
101
+ do_finish: Transition from processing_eof to receiving (cleanup).
102
+
103
+ Example:
104
+ >>> with download_service as service:
105
+ ... service.configure(serial_number="12345678")
106
+ ... service.on_actiontable_received.connect(handle_result)
107
+ ... service.start_reactor()
108
+ """
109
+
110
+ # States - unified for INIT and CLEANUP phases using guards
111
+ idle = State(initial=True)
112
+ receiving = State() # Drain telegrams (INIT or CLEANUP phase)
113
+ resetting = State() # Query error status
114
+ waiting_ok = State() # Await error status response
115
+
116
+ requesting = State() # DOWNLOAD phase: send download request
117
+ waiting_data = State() # DOWNLOAD phase: await chunks
118
+ receiving_chunk = State() # DOWNLOAD phase: process chunk
119
+ processing_eof = State() # DOWNLOAD phase: deserialize result
120
+
121
+ completed = State(final=True)
122
+
123
+ # Phase transitions - shared states with guards for phase-dependent routing
124
+ do_connect = idle.to(receiving)
125
+ filter_telegram = receiving.to(receiving) # Self-transition: drain to /dev/null
126
+ do_timeout = receiving.to(resetting) | waiting_ok.to(receiving)
127
+ send_error_status = resetting.to(waiting_ok)
128
+ error_status_received = waiting_ok.to(
129
+ receiving, cond="can_retry"
130
+ ) # Retry if under limit
131
+
132
+ # Conditional transitions based on phase
133
+ no_error_status_received = waiting_ok.to(
134
+ requesting, cond="is_init_phase"
135
+ ) | waiting_ok.to(completed, cond="is_cleanup_phase")
136
+
137
+ # DOWNLOAD phase transitions
138
+ send_download = requesting.to(waiting_data)
139
+ receive_chunk = waiting_data.to(receiving_chunk)
140
+ send_ack = receiving_chunk.to(waiting_data)
141
+ receive_eof = waiting_data.to(processing_eof)
142
+
143
+ # Return to drain/reset cycle for CLEANUP phase
144
+ do_finish = processing_eof.to(receiving)
35
145
 
36
146
  def __init__(
37
147
  self,
@@ -51,29 +161,133 @@ class ActionTableDownloadService:
51
161
  self.telegram_service = telegram_service
52
162
  self.serial_number: str = ""
53
163
  self.actiontable_data: list[str] = []
54
- # Set up logging
55
164
  self.logger = logging.getLogger(__name__)
165
+ self._phase: Phase = Phase.INIT
166
+ self._error_retry_count: int = 0
167
+ self._signals_connected: bool = False
168
+
169
+ # Signals (instance attributes to avoid conflict with statemachine)
170
+ self.on_progress: SignalInstance = SignalInstance((str,))
171
+ self.on_error: SignalInstance = SignalInstance((str,))
172
+ self.on_finish: SignalInstance = SignalInstance()
173
+ self.on_actiontable_received: SignalInstance = SignalInstance(
174
+ (ActionTable, dict[str, Any], list[str])
175
+ )
176
+
177
+ # Initialize state machine first (before connecting signals)
178
+ super().__init__(allow_event_without_transition=True)
56
179
 
57
180
  # Connect protocol signals
58
- self.conbus_protocol.on_connection_made.connect(self.connection_made)
59
- self.conbus_protocol.on_telegram_sent.connect(self.telegram_sent)
60
- self.conbus_protocol.on_telegram_received.connect(self.telegram_received)
61
- self.conbus_protocol.on_timeout.connect(self.timeout)
62
- self.conbus_protocol.on_failed.connect(self.failed)
181
+ self._connect_signals()
63
182
 
64
- def connection_made(self) -> None:
65
- """Handle connection established event."""
66
- self.logger.debug(
67
- "Connection established, sending download actiontable telegram"
183
+ # Guard conditions for phase-dependent transitions
184
+
185
+ def is_init_phase(self) -> bool:
186
+ """Guard: check if currently in INIT phase.
187
+
188
+ Returns:
189
+ True if in INIT phase, False otherwise.
190
+ """
191
+ return self._phase == Phase.INIT
192
+
193
+ def is_cleanup_phase(self) -> bool:
194
+ """Guard: check if currently in CLEANUP phase.
195
+
196
+ Returns:
197
+ True if in CLEANUP phase, False otherwise.
198
+ """
199
+ return self._phase == Phase.CLEANUP
200
+
201
+ def can_retry(self) -> bool:
202
+ """Guard: check if retry is allowed (under max limit).
203
+
204
+ Returns:
205
+ True if retry count is below MAX_ERROR_RETRIES, False otherwise.
206
+ """
207
+ return self._error_retry_count < MAX_ERROR_RETRIES
208
+
209
+ # State machine lifecycle hooks
210
+ # Note: receiving state is used to drain pending telegrams from the connection
211
+ # pipe. Any telegram received in this state is intentionally discarded (sent
212
+ # to /dev/null) to ensure a clean state before processing.
213
+
214
+ def on_enter_receiving(self) -> None:
215
+ """Enter receiving state - drain pending telegrams."""
216
+ self.logger.debug(f"Entering RECEIVING state (phase={self._phase.value})")
217
+ self.conbus_protocol.wait()
218
+
219
+ def on_enter_resetting(self) -> None:
220
+ """Enter resetting state - query error status."""
221
+ self.logger.debug(f"Entering RESETTING state (phase={self._phase.value})")
222
+ self.conbus_protocol.send_telegram(
223
+ telegram_type=TelegramType.SYSTEM,
224
+ serial_number=self.serial_number,
225
+ system_function=SystemFunction.READ_DATAPOINT,
226
+ data_value=DataPointType.MODULE_ERROR_CODE.value,
68
227
  )
228
+ self.send_error_status()
229
+
230
+ def on_enter_waiting_ok(self) -> None:
231
+ """Enter waiting_ok state - awaiting error status response."""
232
+ self.logger.debug(f"Entering WAITING_OK state (phase={self._phase.value})")
233
+ self.conbus_protocol.wait()
234
+
235
+ def on_enter_requesting(self) -> None:
236
+ """Enter requesting state - send download request."""
237
+ self._phase = Phase.DOWNLOAD
238
+ self.logger.debug("Entering REQUESTING state - sending download request")
69
239
  self.conbus_protocol.send_telegram(
70
240
  telegram_type=TelegramType.SYSTEM,
71
241
  serial_number=self.serial_number,
72
242
  system_function=SystemFunction.DOWNLOAD_ACTIONTABLE,
73
- data_value="00",
243
+ data_value=NO_ERROR_CODE,
74
244
  )
245
+ self.send_download()
246
+
247
+ def on_enter_waiting_data(self) -> None:
248
+ """Enter waiting_data state - wait for actiontable chunks."""
249
+ self.logger.debug("Entering WAITING_DATA state - awaiting chunks")
250
+ self.conbus_protocol.wait()
251
+
252
+ def on_enter_receiving_chunk(self) -> None:
253
+ """Enter receiving_chunk state - send ACK."""
254
+ self.logger.debug("Entering RECEIVING_CHUNK state - sending ACK")
255
+ self.conbus_protocol.send_telegram(
256
+ telegram_type=TelegramType.SYSTEM,
257
+ serial_number=self.serial_number,
258
+ system_function=SystemFunction.ACK,
259
+ data_value=NO_ERROR_CODE,
260
+ )
261
+ self.send_ack()
262
+
263
+ def on_enter_processing_eof(self) -> None:
264
+ """Enter processing_eof state - deserialize and emit result, then cleanup."""
265
+ self.logger.debug("Entering PROCESSING_EOF state - deserializing")
266
+ all_data = "".join(self.actiontable_data)
267
+ actiontable = self.serializer.from_encoded_string(all_data)
268
+ actiontable_dict = asdict(actiontable)
269
+ actiontable_short = self.serializer.format_decoded_output(actiontable)
270
+ self.on_actiontable_received.emit(
271
+ actiontable, actiontable_dict, actiontable_short
272
+ )
273
+ # Switch to CLEANUP phase before returning to receiving state
274
+ self._phase = Phase.CLEANUP
275
+ self.do_finish()
276
+
277
+ def on_enter_completed(self) -> None:
278
+ """Enter completed state - download finished."""
279
+ self.logger.debug("Entering COMPLETED state - download finished")
280
+ self.on_finish.emit()
281
+
282
+ # Protocol event handlers
75
283
 
76
- def telegram_sent(self, telegram_sent: str) -> None:
284
+ def _on_connection_made(self) -> None:
285
+ """Handle connection established event."""
286
+ self.logger.debug("Connection made")
287
+ if self.idle.is_active:
288
+ self.do_connect()
289
+
290
+ def _on_telegram_sent(self, telegram_sent: str) -> None:
77
291
  """Handle telegram sent event.
78
292
 
79
293
  Args:
@@ -81,59 +295,128 @@ class ActionTableDownloadService:
81
295
  """
82
296
  self.logger.debug(f"Telegram sent: {telegram_sent}")
83
297
 
84
- def telegram_received(self, telegram_received: TelegramReceivedEvent) -> None:
298
+ def _on_read_datapoint_received(self, reply_telegram: ReplyTelegram) -> None:
299
+ """Handle READ_DATAPOINT response for error status check.
300
+
301
+ Args:
302
+ reply_telegram: The parsed reply telegram.
303
+ """
304
+ self.logger.debug(f"Received READ_DATAPOINT in {self.current_state}")
305
+
306
+ if reply_telegram.datapoint_type != DataPointType.MODULE_ERROR_CODE:
307
+ self.logger.debug(
308
+ f"Filtered: not a MODULE_ERROR_CODE (got {reply_telegram.datapoint_type})"
309
+ )
310
+ return
311
+
312
+ if not self.waiting_ok.is_active:
313
+ return
314
+
315
+ is_no_error = reply_telegram.data_value == NO_ERROR_CODE
316
+ if is_no_error:
317
+ self._error_retry_count = 0 # Reset on success
318
+ self.no_error_status_received() # Guards determine target state
319
+ else:
320
+ self._error_retry_count += 1
321
+ self.logger.debug(
322
+ f"Error status received, retry {self._error_retry_count}/{MAX_ERROR_RETRIES}"
323
+ )
324
+ # Guard can_retry blocks transition if max retries exceeded
325
+ self.error_status_received()
326
+ # Check if guard blocked the transition (still in waiting_ok)
327
+ if self.waiting_ok.is_active:
328
+ self.logger.error(
329
+ f"Max error retries ({MAX_ERROR_RETRIES}) exceeded, giving up"
330
+ )
331
+ self.on_error.emit(
332
+ f"Module error persists after {MAX_ERROR_RETRIES} retries"
333
+ )
334
+
335
+ def _on_actiontable_chunk_received(self, reply_telegram: ReplyTelegram) -> None:
336
+ """Handle actiontable chunk telegram received.
337
+
338
+ Args:
339
+ reply_telegram: The parsed reply telegram containing chunk data.
340
+ """
341
+ self.logger.debug(f"Received actiontable chunk in {self.current_state}")
342
+ if self.waiting_data.is_active:
343
+ data_part = reply_telegram.data_value[CHUNK_HEADER_LENGTH:]
344
+ self.actiontable_data.append(data_part)
345
+ self.on_progress.emit(".")
346
+ self.receive_chunk()
347
+
348
+ def _on_eof_received(self, _reply_telegram: ReplyTelegram) -> None:
349
+ """Handle EOF telegram received.
350
+
351
+ Args:
352
+ _reply_telegram: The parsed reply telegram (unused).
353
+ """
354
+ self.logger.debug(f"Received EOF in {self.current_state}")
355
+ if self.waiting_data.is_active:
356
+ self.receive_eof()
357
+
358
+ def _on_telegram_received(self, telegram_received: TelegramReceivedEvent) -> None:
85
359
  """Handle telegram received event.
86
360
 
87
361
  Args:
88
362
  telegram_received: The telegram received event.
89
363
  """
90
- self.logger.debug(f"Telegram received: {telegram_received}")
91
- if (
92
- not telegram_received.checksum_valid
93
- or telegram_received.telegram_type != TelegramType.REPLY.value
94
- or telegram_received.serial_number != self.serial_number
95
- ):
96
- self.logger.debug("Not a reply response")
364
+ self.logger.debug(f"Received {telegram_received} in {self.current_state}")
365
+
366
+ # In receiving state, drain pending telegrams from pipe (discard to /dev/null).
367
+ # This ensures clean state before processing by clearing any stale messages.
368
+ if self.receiving.is_active:
369
+ self.filter_telegram()
370
+ return
371
+
372
+ # Filter invalid telegrams
373
+ if not telegram_received.checksum_valid:
374
+ self.logger.debug("Filtered: invalid checksum")
375
+ return
376
+
377
+ if telegram_received.telegram_type != TelegramType.REPLY.value:
378
+ self.logger.debug(
379
+ f"Filtered: not a reply (got {telegram_received.telegram_type})"
380
+ )
381
+ return
382
+
383
+ if telegram_received.serial_number != self.serial_number:
384
+ self.logger.debug(
385
+ f"Filtered: wrong serial {telegram_received.serial_number} != {self.serial_number}"
386
+ )
97
387
  return
98
388
 
99
389
  reply_telegram = self.telegram_service.parse_reply_telegram(
100
390
  telegram_received.frame
101
391
  )
102
- if reply_telegram.system_function not in (
103
- SystemFunction.ACTIONTABLE,
104
- SystemFunction.EOF,
105
- ):
106
- self.logger.debug("Not a actiontable response")
392
+
393
+ if reply_telegram.system_function == SystemFunction.READ_DATAPOINT:
394
+ self._on_read_datapoint_received(reply_telegram)
107
395
  return
108
396
 
109
397
  if reply_telegram.system_function == SystemFunction.ACTIONTABLE:
110
- self.logger.debug("Saving actiontable response")
111
- data_part = reply_telegram.data_value[2:]
112
- self.actiontable_data.append(data_part)
113
- self.on_progress.emit(".")
114
-
115
- self.conbus_protocol.send_telegram(
116
- telegram_type=TelegramType.SYSTEM,
117
- serial_number=self.serial_number,
118
- system_function=SystemFunction.ACK,
119
- data_value="00",
120
- )
398
+ self._on_actiontable_chunk_received(reply_telegram)
121
399
  return
122
400
 
123
401
  if reply_telegram.system_function == SystemFunction.EOF:
124
- all_data = "".join(self.actiontable_data)
125
- # Deserialize from received data
126
- actiontable = self.serializer.from_encoded_string(all_data)
127
- actiontable_dict = asdict(actiontable)
128
- actiontable_short = self.serializer.format_decoded_output(actiontable)
129
- self.on_finish.emit(actiontable, actiontable_dict, actiontable_short)
130
-
131
- def timeout(self) -> None:
132
- """Handle timeout event."""
133
- self.logger.debug("Timeout occurred")
134
- self.failed("Timeout")
402
+ self._on_eof_received(reply_telegram)
403
+ return
135
404
 
136
- def failed(self, message: str) -> None:
405
+ def _on_timeout(self) -> None:
406
+ """Handle timeout event."""
407
+ self.logger.debug(f"Timeout occurred (phase={self._phase.value})")
408
+ if self.receiving.is_active:
409
+ self.do_timeout() # receiving -> resetting
410
+ elif self.waiting_ok.is_active:
411
+ self.do_timeout() # waiting_ok -> receiving (retry)
412
+ elif self.waiting_data.is_active:
413
+ self.logger.error("Timeout waiting for actiontable data")
414
+ self.on_error.emit("Timeout waiting for actiontable data")
415
+ else:
416
+ self.logger.debug("Timeout in non-recoverable state")
417
+ self.on_error.emit("Timeout")
418
+
419
+ def _on_failed(self, message: str) -> None:
137
420
  """Handle failed connection event.
138
421
 
139
422
  Args:
@@ -142,22 +425,31 @@ class ActionTableDownloadService:
142
425
  self.logger.debug(f"Failed: {message}")
143
426
  self.on_error.emit(message)
144
427
 
145
- def start(
428
+ # Public API
429
+
430
+ def configure(
146
431
  self,
147
432
  serial_number: str,
148
- timeout_seconds: Optional[float] = None,
433
+ timeout_seconds: Optional[float] = 2.0,
149
434
  ) -> None:
150
- """Run reactor in dedicated thread with its own event loop.
435
+ """Configure download parameters before starting.
436
+
437
+ Sets the target module serial number and timeout. Call this before
438
+ start_reactor() to configure the download target.
151
439
 
152
440
  Args:
153
- serial_number: Module serial number.
154
- timeout_seconds: Optional timeout in seconds.
441
+ serial_number: Module serial number to download from.
442
+ timeout_seconds: Timeout in seconds for each operation (default 2.0).
443
+
444
+ Raises:
445
+ RuntimeError: If called while download is in progress.
155
446
  """
156
- self.logger.info("Starting actiontable")
447
+ if not self.idle.is_active:
448
+ raise RuntimeError("Cannot configure while download in progress")
449
+ self.logger.info("Configuring actiontable download")
157
450
  self.serial_number = serial_number
158
451
  if timeout_seconds:
159
452
  self.conbus_protocol.timeout_seconds = timeout_seconds
160
- # Caller invokes start_reactor()
161
453
 
162
454
  def set_timeout(self, timeout_seconds: float) -> None:
163
455
  """Set operation timeout.
@@ -175,29 +467,59 @@ class ActionTableDownloadService:
175
467
  """Stop the reactor."""
176
468
  self.conbus_protocol.stop_reactor()
177
469
 
470
+ def _connect_signals(self) -> None:
471
+ """Connect protocol signals to handlers (idempotent)."""
472
+ if self._signals_connected:
473
+ return
474
+ self.conbus_protocol.on_connection_made.connect(self._on_connection_made)
475
+ self.conbus_protocol.on_telegram_sent.connect(self._on_telegram_sent)
476
+ self.conbus_protocol.on_telegram_received.connect(self._on_telegram_received)
477
+ self.conbus_protocol.on_timeout.connect(self._on_timeout)
478
+ self.conbus_protocol.on_failed.connect(self._on_failed)
479
+ self._signals_connected = True
480
+
481
+ def _disconnect_signals(self) -> None:
482
+ """Disconnect protocol signals from handlers (idempotent)."""
483
+ if not self._signals_connected:
484
+ return
485
+ self.conbus_protocol.on_connection_made.disconnect(self._on_connection_made)
486
+ self.conbus_protocol.on_telegram_sent.disconnect(self._on_telegram_sent)
487
+ self.conbus_protocol.on_telegram_received.disconnect(self._on_telegram_received)
488
+ self.conbus_protocol.on_timeout.disconnect(self._on_timeout)
489
+ self.conbus_protocol.on_failed.disconnect(self._on_failed)
490
+ self._signals_connected = False
491
+
178
492
  def __enter__(self) -> "ActionTableDownloadService":
179
- """Enter context manager - reset state for singleton reuse.
493
+ """Enter context manager - reset state and reconnect signals.
180
494
 
181
495
  Returns:
182
496
  Self for context manager protocol.
183
497
  """
184
498
  # Reset state for singleton reuse
185
499
  self.actiontable_data = []
500
+ self._phase = Phase.INIT
501
+ self._error_retry_count = 0
502
+ # Reset state machine to idle
503
+ self._reset_state()
504
+ # Reconnect signals (in case previously disconnected)
505
+ self._connect_signals()
186
506
  return self
187
507
 
508
+ def _reset_state(self) -> None:
509
+ """Reset state machine to initial state."""
510
+ # python-statemachine uses model.state to track current state
511
+ # Set it directly to the initial state id
512
+ self.model.state = self.idle.id
513
+
188
514
  def __exit__(
189
515
  self, _exc_type: Optional[type], _exc_val: Optional[Exception], _exc_tb: Any
190
516
  ) -> None:
191
517
  """Exit context manager and disconnect signals."""
192
- # Disconnect protocol signals
193
- self.conbus_protocol.on_connection_made.disconnect(self.connection_made)
194
- self.conbus_protocol.on_telegram_sent.disconnect(self.telegram_sent)
195
- self.conbus_protocol.on_telegram_received.disconnect(self.telegram_received)
196
- self.conbus_protocol.on_timeout.disconnect(self.timeout)
197
- self.conbus_protocol.on_failed.disconnect(self.failed)
518
+ self._disconnect_signals()
198
519
  # Disconnect service signals
199
520
  self.on_progress.disconnect()
200
521
  self.on_error.disconnect()
522
+ self.on_actiontable_received.disconnect()
201
523
  self.on_finish.disconnect()
202
524
  # Stop reactor
203
525
  self.stop_reactor()
@@ -103,6 +103,16 @@ class ConbusEventProtocol(protocol.Protocol, protocol.ClientFactory):
103
103
  # Start inactivity timeout
104
104
  self._reset_timeout()
105
105
 
106
+ def wait(self, wait_timeout: Optional[float] = None) -> None:
107
+ """Wait for incoming telegrams with optional timeout override.
108
+
109
+ Args:
110
+ wait_timeout: Optional timeout in seconds to override default.
111
+ """
112
+ if wait_timeout:
113
+ self.timeout_seconds = wait_timeout
114
+ self._reset_timeout()
115
+
106
116
  def dataReceived(self, data: bytes) -> None:
107
117
  """Handle received data from TCP connection.
108
118
 
@@ -337,7 +337,7 @@ class ServerService:
337
337
  self.logger.warning(f"Failed to parse telegram: {telegram}")
338
338
  return responses
339
339
 
340
- self.client_buffers.broadcast(parsed_telegram.raw_telegram)
340
+ # self.client_buffers.broadcast(parsed_telegram.raw_telegram)
341
341
 
342
342
  # Handle discover requests
343
343
  if self.discover_service.is_discover_request(parsed_telegram):