flashforge-python-api 1.0.2__py3-none-any.whl → 1.2.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.
Files changed (43) hide show
  1. flashforge/__init__.py +70 -29
  2. flashforge/api/__init__.py +1 -0
  3. flashforge/api/constants/__init__.py +1 -0
  4. flashforge/api/constants/commands.py +1 -0
  5. flashforge/api/constants/endpoints.py +7 -0
  6. flashforge/api/controls/__init__.py +1 -0
  7. flashforge/api/controls/control.py +64 -61
  8. flashforge/api/controls/files.py +28 -26
  9. flashforge/api/controls/info.py +84 -85
  10. flashforge/api/controls/job_control.py +120 -82
  11. flashforge/api/controls/temp_control.py +14 -11
  12. flashforge/api/misc/__init__.py +1 -1
  13. flashforge/api/network/__init__.py +2 -1
  14. flashforge/api/network/utils.py +7 -8
  15. flashforge/client.py +185 -64
  16. flashforge/discovery/__init__.py +30 -3
  17. flashforge/discovery/discovery.py +641 -308
  18. flashforge/models/__init__.py +11 -10
  19. flashforge/models/machine_info.py +225 -100
  20. flashforge/models/responses.py +103 -43
  21. flashforge/tcp/__init__.py +30 -17
  22. flashforge/tcp/a3_client.py +408 -0
  23. flashforge/tcp/a4_client.py +300 -0
  24. flashforge/tcp/ff_client.py +93 -90
  25. flashforge/tcp/gcode/__init__.py +4 -2
  26. flashforge/tcp/gcode/a3_gcode_controller.py +109 -0
  27. flashforge/tcp/gcode/gcode_controller.py +48 -36
  28. flashforge/tcp/gcode/gcodes.py +7 -1
  29. flashforge/tcp/parsers/__init__.py +11 -11
  30. flashforge/tcp/parsers/endstop_status.py +103 -132
  31. flashforge/tcp/parsers/location_info.py +26 -13
  32. flashforge/tcp/parsers/print_status.py +49 -56
  33. flashforge/tcp/parsers/printer_info.py +38 -48
  34. flashforge/tcp/parsers/temp_info.py +46 -47
  35. flashforge/tcp/parsers/thumbnail_info.py +65 -40
  36. flashforge/tcp/tcp_client.py +296 -293
  37. flashforge_python_api-1.2.0.dist-info/METADATA +133 -0
  38. flashforge_python_api-1.2.0.dist-info/RECORD +46 -0
  39. {flashforge_python_api-1.0.2.dist-info → flashforge_python_api-1.2.0.dist-info}/WHEEL +1 -1
  40. flashforge_python_api-1.0.2.dist-info/METADATA +0 -284
  41. flashforge_python_api-1.0.2.dist-info/RECORD +0 -43
  42. {flashforge_python_api-1.0.2.dist-info → flashforge_python_api-1.2.0.dist-info}/entry_points.txt +0 -0
  43. {flashforge_python_api-1.0.2.dist-info → flashforge_python_api-1.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -2,122 +2,90 @@
2
2
  Low-level TCP client for communicating with FlashForge 3D printers.
3
3
 
4
4
  This module provides the foundational TCP communication layer, managing socket connections,
5
- sending raw commands, handling responses, and maintaining keep-alive connections.
5
+ sending raw commands, handling responses, maintaining keep-alive connections, and supporting
6
+ legacy raw-binary uploads used by Adventurer-class printers.
6
7
  """
7
8
 
9
+ from __future__ import annotations
10
+
8
11
  import asyncio
9
12
  import logging
10
- from typing import List, Optional
13
+ import re
14
+ from dataclasses import dataclass
15
+ from pathlib import Path, PurePosixPath
11
16
 
12
17
  from .gcode.gcodes import GCodes
13
18
 
14
19
  logger = logging.getLogger(__name__)
15
20
 
21
+ # Regex for invalid characters in filenames
22
+ INVALID_FILENAME_CHARS_PATTERN = re.compile(r"[^\w\s\-\.\(\)\+%,@\[\]{}:;!#$^&*=<>?\/]")
23
+
24
+
25
+ @dataclass(slots=True)
26
+ class FlashForgeTcpClientOptions:
27
+ """Optional transport overrides for the TCP client."""
28
+
29
+ port: int | None = None
30
+
16
31
 
17
32
  class FlashForgeTcpClient:
18
33
  """
19
- Foundational TCP client for communicating with FlashForge 3D printers.
20
-
21
- This class manages the socket connection, sending raw commands, handling responses,
22
- and maintaining a keep-alive connection. It serves as the base class for
23
- FlashForgeClient, which implements more specific G-code command logic.
24
-
25
- The communication protocol typically involves sending ASCII G-code/M-code commands
26
- terminated by a newline character ('\\n') and receiving text-based responses,
27
- often ending with "ok" to indicate success.
34
+ Foundational TCP client for communicating with FlashForge printers.
35
+
36
+ This class manages the socket connection, serializes commands over a single stream,
37
+ and handles protocol-specific response completion behavior such as the widened M661
38
+ settle window and legacy M28/M29 upload semantics.
28
39
  """
29
40
 
30
- def __init__(self, hostname: str) -> None:
31
- """
32
- Create an instance of FlashForgeTcpClient.
33
-
34
- Initializes the hostname and attempts to connect to the printer.
35
-
36
- Args:
37
- hostname: The IP address or hostname of the FlashForge printer
38
- """
41
+ def __init__(self, hostname: str, options: FlashForgeTcpClientOptions | None = None) -> None:
39
42
  self.hostname = hostname
40
- self.port = 8899
41
- """The default TCP port used for connecting to FlashForge printers."""
42
-
43
+ self.port = options.port if options and options.port is not None else 8899
43
44
  self.timeout = 5.0
44
- """The default timeout (in seconds) for socket operations."""
45
-
46
- self._reader: Optional[asyncio.StreamReader] = None
47
- self._writer: Optional[asyncio.StreamWriter] = None
48
- """The underlying network streams for TCP communication."""
49
-
50
- self._keep_alive_task: Optional[asyncio.Task] = None
51
- """Task for the keep-alive mechanism."""
52
45
 
46
+ self._reader: asyncio.StreamReader | None = None
47
+ self._writer: asyncio.StreamWriter | None = None
48
+ self._keep_alive_task: asyncio.Task[None] | None = None
53
49
  self._keep_alive_cancellation_token = False
54
- """Token to signal cancellation of the keep-alive loop."""
55
-
56
50
  self._keep_alive_errors = 0
57
- """Counter for consecutive keep-alive errors."""
58
-
59
- self._socket_busy = False
60
- """Flag indicating if the socket is currently busy sending a command and awaiting a response."""
61
-
62
51
  self._socket_lock = asyncio.Lock()
63
- """Lock to ensure only one command is sent at a time."""
64
52
 
65
- try:
66
- logger.info("TcpPrinterClient creation")
67
- # Note: We don't connect immediately in Python - connection is established on first use
68
- logger.info("Initialized (connection will be established on first command)")
69
- except Exception:
70
- logger.error("TcpPrinterClient failed to init!")
71
- raise
53
+ logger.info("TcpPrinterClient creation")
54
+ logger.info("Initialized (connection will be established on first command)")
72
55
 
73
56
  async def start_keep_alive(self) -> None:
74
- """
75
- Start a keep-alive mechanism to maintain the TCP connection with the printer.
76
-
77
- Periodically sends a status command (GCodes.CMD_PRINT_STATUS) to the printer.
78
- Adjusts the keep-alive interval based on error counts.
79
- This method runs asynchronously and will continue until stop_keep_alive is called
80
- or too many consecutive errors occur.
81
- """
57
+ """Start the TCP keep-alive loop."""
82
58
  if self._keep_alive_task and not self._keep_alive_task.done():
83
- return # Already running
59
+ return
84
60
 
85
61
  self._keep_alive_cancellation_token = False
86
62
 
87
- async def run_keep_alive():
63
+ async def run_keep_alive() -> None:
88
64
  try:
89
65
  while not self._keep_alive_cancellation_token:
90
- # logger.debug("KeepAlive")
91
66
  result = await self.send_command_async(GCodes.CMD_PRINT_STATUS)
92
67
  if result is None:
93
- # Keep alive failed, connection error/timeout etc
94
- self._keep_alive_errors += 1 # Keep track of errors
95
- # logger.debug(f"Current keep alive failure: {self._keep_alive_errors}")
68
+ self._keep_alive_errors += 1
96
69
  break
97
70
 
98
71
  if self._keep_alive_errors > 0:
99
- self._keep_alive_errors -= 1 # Move back to 0 errors with each "good" keep-alive
72
+ self._keep_alive_errors -= 1
100
73
 
101
- # Increase keep alive timeout based on error count
102
74
  await asyncio.sleep(5.0 + self._keep_alive_errors * 1.0)
103
-
75
+ except asyncio.CancelledError:
76
+ raise
104
77
  except Exception as error:
105
- logger.error(f"KeepAlive encountered an exception: {error}")
78
+ logger.error("KeepAlive encountered an exception: %s", error)
106
79
 
107
80
  self._keep_alive_task = asyncio.create_task(run_keep_alive())
108
81
 
109
82
  async def stop_keep_alive(self, logout: bool = False) -> None:
110
- """
111
- Stop the keep-alive mechanism.
112
-
113
- Args:
114
- logout: If True, sends a logout command to the printer before stopping
115
- """
83
+ """Stop the keep-alive loop, optionally logging out first."""
116
84
  if logout:
117
85
  try:
118
- await self.send_command_async(GCodes.CMD_LOGOUT) # Release control
86
+ await self.send_command_async(GCodes.CMD_LOGOUT)
119
87
  except Exception:
120
- pass # Ignore errors during logout
88
+ pass
121
89
 
122
90
  self._keep_alive_cancellation_token = True
123
91
 
@@ -128,125 +96,134 @@ class FlashForgeTcpClient:
128
96
  except asyncio.CancelledError:
129
97
  pass
130
98
 
99
+ self._keep_alive_task = None
131
100
  logger.info("Keep-alive stopped.")
132
101
 
133
- async def is_socket_busy(self) -> bool:
134
- """
135
- Check if the socket is currently busy processing a command.
136
-
137
- Returns:
138
- True if the socket is busy, False otherwise
139
- """
140
- return self._socket_busy
141
-
142
- async def send_command_async(self, cmd: str) -> Optional[str]:
102
+ async def send_command_async(self, cmd: str) -> str | None:
143
103
  """
144
104
  Send a command string to the printer asynchronously via the TCP socket.
145
-
146
- It ensures the socket is available, writes the command (appending a newline),
147
- and then waits to receive a multi-line reply.
148
- Handles socket busy state and various connection errors.
149
-
150
- Args:
151
- cmd: The command string to send (e.g., "~M115")
152
-
153
- Returns:
154
- The printer's string reply, or None if an error occurs,
155
- the reply is invalid, or the connection needs to be reset
105
+
106
+ Commands are serialized over a single socket lock so the reader can safely
107
+ associate each response with the command that produced it.
156
108
  """
157
109
  async with self._socket_lock:
158
- self._socket_busy = True
159
-
160
- logger.debug(f"sendCommand: {cmd}")
110
+ logger.debug("sendCommand: %s", cmd)
161
111
  try:
162
112
  await self._check_socket()
163
113
 
164
- # Write command
165
- self._writer.write((cmd + '\n').encode('ascii'))
114
+ if self._writer is None:
115
+ logger.error("Writer is None after _check_socket, cannot send command")
116
+ return None
117
+
118
+ self._writer.write((cmd + "\n").encode("ascii"))
166
119
  await self._writer.drain()
167
120
 
168
- # Receive response
169
- reply = await self._receive_multi_line_replay_async(cmd)
121
+ if self.should_skip_response_wait(cmd):
122
+ return ""
170
123
 
124
+ reply = await self._receive_multi_line_replay_async(cmd)
171
125
  if reply is not None:
172
- # logger.debug(f"Received reply for command: {reply}")
173
126
  return reply
174
- else:
175
- logger.warning("Invalid or no reply received, resetting connection to printer.")
176
- await self._reset_socket()
177
- await self._check_socket()
178
- return None
127
+
128
+ logger.warning("Invalid or no reply received, resetting connection to printer.")
129
+ await self._reset_socket()
130
+ await self._check_socket()
131
+ return None
179
132
 
180
133
  except Exception as error:
181
- logger.error(f"Error while sending command: {error}")
134
+ logger.error("Error while sending command: %s", error)
182
135
  return None
183
- finally:
184
- self._socket_busy = False
185
136
 
186
- async def _wait_until_socket_available(self) -> None:
137
+ async def upload_file(self, local_file_path: str, remote_file_name: str | None = None) -> bool:
187
138
  """
188
- Wait until the socket is no longer busy or a timeout is reached.
189
-
190
- This is used to serialize commands sent over the socket.
191
-
192
- Raises:
193
- TimeoutError: If the socket remains busy for too long (10 seconds)
139
+ Upload a file to legacy printer storage using the M28/raw-binary/M29 flow.
194
140
  """
195
- max_wait_time = 10.0 # 10 seconds
196
- start_time = asyncio.get_event_loop().time()
141
+ file_path = Path(local_file_path)
142
+ if not file_path.exists() or not file_path.is_file():
143
+ logger.error("Upload failed: %s is not a file.", local_file_path)
144
+ return False
145
+
146
+ normalized_file_name = self._normalize_legacy_upload_filename(
147
+ remote_file_name or local_file_path
148
+ )
149
+ if not normalized_file_name:
150
+ logger.error("Upload failed: remote file name resolved to an empty value.")
151
+ return False
152
+
153
+ start_command = (
154
+ GCodes.CMD_PREP_FILE_UPLOAD.replace("%%size%%", str(file_path.stat().st_size))
155
+ .replace("%%filename%%", normalized_file_name)
156
+ )
157
+
158
+ async with self._socket_lock:
159
+ try:
160
+ await self._check_socket()
161
+ if self._writer is None:
162
+ logger.error("Upload failed: writer is unavailable.")
163
+ return False
164
+
165
+ self._writer.write((start_command + "\n").encode("ascii"))
166
+ await self._writer.drain()
167
+
168
+ start_response = await self._receive_multi_line_replay_async(start_command)
169
+ if not start_response or not self._is_successful_upload_boundary_response(
170
+ start_command, start_response
171
+ ):
172
+ logger.error("Upload failed: printer rejected M28 upload initialization.")
173
+ return False
197
174
 
198
- while self._socket_busy and (asyncio.get_event_loop().time() - start_time < max_wait_time):
199
- await asyncio.sleep(0.1)
175
+ with file_path.open("rb") as upload_stream:
176
+ while chunk := upload_stream.read(64 * 1024):
177
+ self._writer.write(chunk)
178
+ await self._writer.drain()
200
179
 
201
- if self._socket_busy:
202
- raise TimeoutError("Socket remained busy for too long, timing out")
180
+ self._writer.write((GCodes.CMD_COMPLETE_FILE_UPLOAD + "\n").encode("ascii"))
181
+ await self._writer.drain()
182
+
183
+ finish_response = await self._receive_multi_line_replay_async(
184
+ GCodes.CMD_COMPLETE_FILE_UPLOAD
185
+ )
186
+ if not finish_response:
187
+ logger.error("Upload failed: printer did not respond to M29 upload finalization.")
188
+ return False
189
+
190
+ return self._is_successful_upload_boundary_response(
191
+ GCodes.CMD_COMPLETE_FILE_UPLOAD, finish_response
192
+ )
193
+ except Exception as error:
194
+ logger.error("Upload failed for %s: %s", normalized_file_name, error)
195
+ await self._reset_socket()
196
+ return False
203
197
 
204
198
  async def _check_socket(self) -> None:
205
- """
206
- Check the status of the socket connection and attempt to reconnect if needed.
207
-
208
- If reconnection occurs, it also restarts the keep-alive mechanism.
209
- """
210
- logger.debug("CheckSocket()")
199
+ """Reconnect the TCP socket if it is not ready."""
211
200
  fix = False
212
201
 
213
202
  if self._writer is None or self._reader is None:
214
203
  fix = True
215
- # logger.debug("TcpPrinterClient socket is null")
216
204
  elif self._writer.is_closing():
217
205
  fix = True
218
- # logger.debug("TcpPrinterClient socket is closed")
219
206
 
220
207
  if not fix:
221
208
  return
222
209
 
223
210
  logger.warning("Reconnecting to TCP socket...")
224
211
  await self._connect()
225
- await self.start_keep_alive() # Start this here rather than in Connect()
212
+ await self.start_keep_alive()
226
213
 
227
214
  async def _connect(self) -> None:
228
- """
229
- Establish a TCP connection to the printer.
230
-
231
- Initializes the reader and writer streams and sets up error handling.
232
- """
233
- # logger.debug("Connect()")
215
+ """Establish a TCP connection to the printer."""
234
216
  try:
235
217
  self._reader, self._writer = await asyncio.wait_for(
236
218
  asyncio.open_connection(self.hostname, self.port),
237
- timeout=self.timeout
219
+ timeout=self.timeout,
238
220
  )
239
221
  except Exception as error:
240
- logger.error(f"Failed to connect to {self.hostname}:{self.port}: {error}")
222
+ logger.error("Failed to connect to %s:%s: %s", self.hostname, self.port, error)
241
223
  raise
242
224
 
243
225
  async def _reset_socket(self) -> None:
244
- """
245
- Reset the current socket connection.
246
-
247
- Stops the keep-alive mechanism and closes the connection.
248
- """
249
- # logger.debug("ResetSocket()")
226
+ """Reset the current socket connection."""
250
227
  await self.stop_keep_alive()
251
228
  if self._writer:
252
229
  self._writer.close()
@@ -257,185 +234,177 @@ class FlashForgeTcpClient:
257
234
  self._reader = None
258
235
  self._writer = None
259
236
 
260
- async def _receive_multi_line_replay_async(self, cmd: str) -> Optional[str]:
261
- """
262
- Asynchronously receive a multi-line reply from the printer for a given command.
263
-
264
- It listens for data from the reader, concatenates incoming data,
265
- and determines when the full reply has been received based on command-specific delimiters
266
- (usually "ok" for text commands, or specific logic for binary data like thumbnails).
267
- Handles timeouts and errors during reception.
268
-
269
- Args:
270
- cmd: The command string for which the reply is expected. This influences how completion is detected.
271
-
272
- Returns:
273
- The complete string reply from the printer, or None if an error occurs,
274
- the reply is incomplete, or a timeout happens.
275
- For thumbnail commands (M662), the response is a binary string.
276
- """
277
- # logger.debug("ReceiveMultiLineReplayAsync()")
278
-
237
+ async def _receive_multi_line_replay_async(self, cmd: str) -> str | None:
238
+ """Receive a complete multi-line reply for the given command."""
279
239
  if not self._reader:
280
- # logger.error("Reader is null, cannot receive reply.")
281
240
  return None
282
241
 
283
242
  answer = bytearray()
243
+ loop = asyncio.get_running_loop()
244
+ start_time = loop.time()
245
+ last_data_time: float | None = None
246
+ completion_seen = False
247
+ is_binary = self.is_binary_command(cmd)
248
+ timeout_seconds = self.get_command_timeout_ms(cmd) / 1000.0
249
+ settle_delay_seconds = self.get_response_completion_delay_ms(cmd, is_binary) / 1000.0
250
+ inactivity_delay_seconds = self.get_inactivity_completion_delay_ms(cmd) / 1000.0
251
+
252
+ while True:
253
+ elapsed = loop.time() - start_time
254
+ remaining_timeout = timeout_seconds - elapsed
255
+ if remaining_timeout <= 0:
256
+ logger.error(
257
+ "ReceiveMultiLineReplayAsync timed out after %sms",
258
+ self.get_command_timeout_ms(cmd),
259
+ )
260
+ return None
284
261
 
285
- # Set timeout based on command type
286
- timeout_duration = 5.0 # default timeout
287
- if cmd == GCodes.CMD_LIST_LOCAL_FILES or cmd.startswith(GCodes.CMD_GET_THUMBNAIL):
288
- timeout_duration = 10.0 # increase command timeout
289
-
290
- try:
291
- while True:
292
- # Read data with timeout
293
- try:
294
- data = await asyncio.wait_for(self._reader.read(4096), timeout=1.0)
295
- except asyncio.TimeoutError:
296
- # Check if we have a complete response so far
297
- if self._is_response_complete(cmd, answer):
298
- break
299
- continue
300
-
301
- if not data:
302
- logger.error("Connection closed by remote host")
303
- return None
262
+ read_timeout = min(0.1, remaining_timeout) if answer else min(1.0, remaining_timeout)
304
263
 
305
- answer.extend(data)
306
-
307
- # Check for completion
308
- if self._is_response_complete(cmd, answer):
309
- # For file list command, wait a bit more for all data
310
- if cmd == GCodes.CMD_LIST_LOCAL_FILES:
311
- await asyncio.sleep(0.5)
312
- # Try to read any remaining data
313
- try:
314
- additional_data = await asyncio.wait_for(self._reader.read(4096), timeout=0.1)
315
- if additional_data:
316
- answer.extend(additional_data)
317
- except asyncio.TimeoutError:
318
- pass
319
- # For thumbnail requests, wait longer for binary data
320
- elif cmd.startswith(GCodes.CMD_GET_THUMBNAIL):
321
- await asyncio.sleep(1.5)
322
- # Try to read any remaining binary data
323
- try:
324
- additional_data = await asyncio.wait_for(self._reader.read(8192), timeout=0.5)
325
- if additional_data:
326
- answer.extend(additional_data)
327
- except asyncio.TimeoutError:
328
- pass
264
+ try:
265
+ data = await asyncio.wait_for(self._reader.read(8192), timeout=read_timeout)
266
+ except TimeoutError:
267
+ now = loop.time()
268
+ if completion_seen and (
269
+ settle_delay_seconds == 0
270
+ or (last_data_time is not None and now - last_data_time >= settle_delay_seconds)
271
+ ):
329
272
  break
330
273
 
331
- except Exception as e:
332
- logger.error(f"Error receiving multi-line command reply: {e}")
333
- return None
274
+ if (
275
+ answer
276
+ and self.should_use_inactivity_completion(cmd)
277
+ and last_data_time is not None
278
+ and now - last_data_time >= inactivity_delay_seconds
279
+ ):
280
+ break
281
+ continue
334
282
 
335
- # Convert response based on command type
336
- if cmd.startswith(GCodes.CMD_GET_THUMBNAIL):
337
- # For binary responses (M662), return as binary string
338
- result = answer.decode('latin1') # Preserve binary data
339
- if not result:
340
- logger.error("Received empty thumbnail response.")
283
+ if not data:
284
+ logger.error("Connection closed by remote host")
341
285
  return None
342
- return result
343
- else:
344
- # For text responses, convert to UTF-8
345
- try:
346
- result = answer.decode('utf-8')
347
- except UnicodeDecodeError:
348
- # Fallback to latin1 if UTF-8 fails
349
- result = answer.decode('latin1')
350
286
 
287
+ answer.extend(data)
288
+ last_data_time = loop.time()
289
+
290
+ if is_binary:
291
+ if self.is_binary_response_complete(cmd, answer):
292
+ if settle_delay_seconds == 0:
293
+ break
294
+ completion_seen = True
295
+ continue
296
+
297
+ text_so_far = answer.decode("utf-8", errors="ignore")
298
+ if self.is_text_response_complete(cmd, text_so_far):
299
+ if settle_delay_seconds == 0:
300
+ break
301
+ completion_seen = True
302
+
303
+ if is_binary:
304
+ result = answer.decode("latin1")
351
305
  if not result:
352
- logger.error("ReceiveMultiLineReplayAsync received an empty response.")
306
+ logger.error("Received empty binary response.")
353
307
  return None
354
308
  return result
355
309
 
356
- def _is_response_complete(self, cmd: str, data: bytearray) -> bool:
357
- """
358
- Check if the response is complete based on the command type.
359
-
360
- Args:
361
- cmd: The command that was sent
362
- data: The data received so far
363
-
364
- Returns:
365
- True if the response appears complete, False otherwise
366
- """
367
310
  try:
368
- if cmd.startswith(GCodes.CMD_GET_THUMBNAIL):
369
- # For binary responses, check for "ok" in the header only
370
- header = data[:100].decode('ascii', errors='ignore')
371
- return "ok" in header
372
- else:
373
- # For text commands, check for "ok" in the full response
374
- text = data.decode('utf-8', errors='ignore')
375
- return "ok" in text
311
+ result = self.normalize_text_response(cmd, answer.decode("utf-8"))
312
+ except UnicodeDecodeError:
313
+ result = self.normalize_text_response(cmd, answer.decode("latin1"))
314
+
315
+ if not result:
316
+ logger.error("ReceiveMultiLineReplayAsync received an empty response.")
317
+ return None
318
+ return result
319
+
320
+ def should_skip_response_wait(self, _cmd: str) -> bool:
321
+ """Determine whether a command should return as soon as it is written."""
322
+ return False
323
+
324
+ def is_binary_command(self, cmd: str) -> bool:
325
+ """Determine whether a command returns binary payload data."""
326
+ return cmd.startswith(GCodes.CMD_GET_THUMBNAIL)
327
+
328
+ def is_text_response_complete(self, _cmd: str, response: str) -> bool:
329
+ """Determine when a text response is complete."""
330
+ return "ok" in response
331
+
332
+ def should_use_inactivity_completion(self, cmd: str) -> bool:
333
+ """Determine whether a command should complete after a quiet period."""
334
+ return self._is_legacy_upload_boundary_command(cmd)
335
+
336
+ def get_inactivity_completion_delay_ms(self, cmd: str) -> int:
337
+ """Delay used for inactivity-based completion."""
338
+ if self._is_legacy_upload_boundary_command(cmd):
339
+ return 250
340
+ return 200
341
+
342
+ def get_response_completion_delay_ms(self, cmd: str, binary: bool) -> int:
343
+ """Delay added after the completion marker is seen to allow trailing data to arrive."""
344
+ if binary:
345
+ return 1500
346
+ if cmd == GCodes.CMD_LIST_LOCAL_FILES:
347
+ return 1200
348
+ return 0
349
+
350
+ def is_binary_response_complete(self, _cmd: str, response: bytearray) -> bool:
351
+ """Determine whether a binary response buffer is complete."""
352
+ try:
353
+ header = bytes(response[:100]).decode("ascii", errors="ignore")
354
+ return "ok" in header
376
355
  except Exception:
377
356
  return False
378
357
 
379
- async def get_file_list_async(self) -> List[str]:
380
- """
381
- Retrieve a list of G-code files stored on the printer's local storage.
382
-
383
- Sends the CMD_LIST_LOCAL_FILES (M661) command and parses the response.
384
-
385
- Returns:
386
- An array of file names (strings, without '/data/' prefix).
387
- Returns an empty array if the command fails or no files are found.
388
- """
358
+ def normalize_text_response(self, _cmd: str, response: str) -> str:
359
+ """Normalize text responses before returning them to callers."""
360
+ return response
361
+
362
+ def get_command_timeout_ms(self, cmd: str) -> int:
363
+ """Return the socket timeout to use for a given command."""
364
+ if cmd == GCodes.CMD_LIST_LOCAL_FILES or self.is_binary_command(cmd):
365
+ return 10000
366
+ if self._is_legacy_upload_boundary_command(cmd):
367
+ return 10000
368
+ if cmd == GCodes.CMD_HOME_AXES or cmd == "~G28":
369
+ return 15000
370
+ return 5000
371
+
372
+ async def get_file_list_async(self) -> list[str]:
373
+ """Retrieve a list of G-code files stored on the printer."""
389
374
  response = await self.send_command_async(GCodes.CMD_LIST_LOCAL_FILES)
390
375
  if response:
391
376
  return self._parse_file_list_response(response)
392
377
  return []
393
378
 
394
- def _parse_file_list_response(self, response: str) -> List[str]:
395
- """
396
- Parse the raw string response from the M661 (list files) command.
397
-
398
- The response format typically includes segments separated by "::", with file paths
399
- prefixed by "/data/". This method extracts and cleans these file names.
400
-
401
- Args:
402
- response: The raw string response from the M661 command
403
-
404
- Returns:
405
- An array of file names, with the "/data/" prefix removed and any trailing invalid characters trimmed
406
- """
407
- segments = response.split('::')
379
+ def _parse_file_list_response(self, response: str) -> list[str]:
380
+ """Parse the raw string response from the M661 list-files command."""
381
+ segments = response.split("::")
382
+ file_paths: list[str] = []
408
383
 
409
- # Extract file paths
410
- file_paths = []
411
384
  for segment in segments:
412
- data_index = segment.find('/data/')
413
- if data_index != -1:
414
- full_path = segment[data_index:]
415
- if full_path.startswith('/data/'):
416
- filename = full_path[6:] # Remove '/data/' prefix
417
-
418
- # Trim at the first invalid character (if any)
419
- import re
420
- match = re.search(r'[^\w\s\-\.\(\)\+%,@\[\]{}:;!#$^&*=<>?\/]', filename)
421
- if match:
422
- filename = filename[:match.start()]
423
-
424
- # Only add non-empty filenames
425
- if filename.strip():
426
- file_paths.append(filename)
385
+ data_index = segment.find("/data/")
386
+ if data_index == -1:
387
+ continue
388
+
389
+ full_path = segment[data_index:]
390
+ if not full_path.startswith("/data/"):
391
+ continue
392
+
393
+ filename = full_path[6:]
394
+ match = INVALID_FILENAME_CHARS_PATTERN.search(filename)
395
+ if match:
396
+ filename = filename[: match.start()]
397
+
398
+ if filename.strip():
399
+ file_paths.append(filename)
427
400
 
428
401
  return file_paths
429
402
 
430
403
  async def dispose(self) -> None:
431
- """
432
- Clean up resources by closing the socket connection.
433
-
434
- This should be called when the client is no longer needed.
435
- """
404
+ """Clean up resources by closing the socket connection."""
436
405
  try:
437
406
  logger.info("TcpPrinterClient closing socket")
438
- await self.stop_keep_alive(logout=True) # Stop keep-alive timer and send logout command
407
+ await self.stop_keep_alive(logout=True)
439
408
  if self._writer:
440
409
  self._writer.close()
441
410
  try:
@@ -445,4 +414,38 @@ class FlashForgeTcpClient:
445
414
  self._reader = None
446
415
  self._writer = None
447
416
  except Exception as error:
448
- logger.error(f"Error during dispose: {error}")
417
+ logger.error("Error during dispose: %s", error)
418
+
419
+ def _normalize_legacy_upload_filename(self, file_name: str) -> str:
420
+ normalized_path = file_name.replace("\\", "/")
421
+ without_legacy_prefix = re.sub(r"^0:/user/", "", normalized_path, flags=re.IGNORECASE)
422
+ without_legacy_prefix = re.sub(
423
+ r"^/data/",
424
+ "",
425
+ without_legacy_prefix,
426
+ flags=re.IGNORECASE,
427
+ )
428
+ return PurePosixPath(without_legacy_prefix).name
429
+
430
+ def _is_legacy_upload_boundary_command(self, cmd: str) -> bool:
431
+ return bool(re.match(r"^~?M(28|29)\b", cmd.strip(), flags=re.IGNORECASE))
432
+
433
+ def _is_successful_upload_boundary_response(self, cmd: str, response: str) -> bool:
434
+ normalized = response.replace("\r\n", "\n").strip()
435
+ if not normalized:
436
+ return False
437
+
438
+ if re.search(
439
+ r"error:|control failed\.|file is not available|cannot create file|not enough space",
440
+ normalized,
441
+ flags=re.IGNORECASE,
442
+ ):
443
+ return False
444
+
445
+ bare_command = cmd.strip().removeprefix("~").split(maxsplit=1)[0]
446
+ return (
447
+ "ok" in normalized
448
+ or "Received." in normalized
449
+ or re.search(rf"\b{re.escape(bare_command)}\b", normalized, flags=re.IGNORECASE)
450
+ is not None
451
+ )