flashforge-python-api 1.0.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 +188 -0
  2. flashforge/api/__init__.py +17 -0
  3. flashforge/api/constants/__init__.py +10 -0
  4. flashforge/api/constants/commands.py +10 -0
  5. flashforge/api/constants/endpoints.py +11 -0
  6. flashforge/api/controls/__init__.py +16 -0
  7. flashforge/api/controls/control.py +357 -0
  8. flashforge/api/controls/files.py +171 -0
  9. flashforge/api/controls/info.py +292 -0
  10. flashforge/api/controls/job_control.py +561 -0
  11. flashforge/api/controls/temp_control.py +89 -0
  12. flashforge/api/filament/__init__.py +7 -0
  13. flashforge/api/filament/filament.py +39 -0
  14. flashforge/api/misc/__init__.py +8 -0
  15. flashforge/api/misc/scientific_notation.py +28 -0
  16. flashforge/api/misc/temperature.py +42 -0
  17. flashforge/api/network/__init__.py +10 -0
  18. flashforge/api/network/fnet_code.py +15 -0
  19. flashforge/api/network/utils.py +55 -0
  20. flashforge/client.py +381 -0
  21. flashforge/discovery/__init__.py +12 -0
  22. flashforge/discovery/discovery.py +388 -0
  23. flashforge/models/__init__.py +50 -0
  24. flashforge/models/machine_info.py +247 -0
  25. flashforge/models/responses.py +123 -0
  26. flashforge/tcp/__init__.py +41 -0
  27. flashforge/tcp/ff_client.py +587 -0
  28. flashforge/tcp/gcode/__init__.py +11 -0
  29. flashforge/tcp/gcode/gcode_controller.py +296 -0
  30. flashforge/tcp/gcode/gcodes.py +93 -0
  31. flashforge/tcp/parsers/__init__.py +27 -0
  32. flashforge/tcp/parsers/endstop_status.py +273 -0
  33. flashforge/tcp/parsers/location_info.py +68 -0
  34. flashforge/tcp/parsers/print_status.py +182 -0
  35. flashforge/tcp/parsers/printer_info.py +154 -0
  36. flashforge/tcp/parsers/temp_info.py +217 -0
  37. flashforge/tcp/parsers/thumbnail_info.py +242 -0
  38. flashforge/tcp/tcp_client.py +448 -0
  39. flashforge_python_api-1.0.0.dist-info/METADATA +123 -0
  40. flashforge_python_api-1.0.0.dist-info/RECORD +43 -0
  41. flashforge_python_api-1.0.0.dist-info/WHEEL +4 -0
  42. flashforge_python_api-1.0.0.dist-info/entry_points.txt +2 -0
  43. flashforge_python_api-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,242 @@
1
+ """
2
+ FlashForge Python API - Thumbnail Info Parser
3
+
4
+ Handles the parsing, storage, and manipulation of 3D print file thumbnail images.
5
+ """
6
+ import base64
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+
11
+ class ThumbnailInfo:
12
+ """
13
+ Handles the parsing, storage, and manipulation of 3D print file thumbnail images.
14
+
15
+ Thumbnails are typically retrieved from the printer using a command like M662,
16
+ which returns a mixed response containing text (e.g., "ok") followed by raw binary PNG data.
17
+ This class provides methods to extract the PNG data, convert it to various formats, and save it to a file.
18
+ """
19
+
20
+ def __init__(self):
21
+ """Initialize a new ThumbnailInfo instance."""
22
+ self._image_data: Optional[bytes] = None
23
+ self._file_name: Optional[str] = None
24
+
25
+ def from_replay(self, replay: str, file_name: str) -> Optional['ThumbnailInfo']:
26
+ """
27
+ Parses thumbnail data from a raw printer response string.
28
+
29
+ The method expects the response to contain an "ok" text delimiter, after which
30
+ the binary PNG data begins. It searches for the PNG signature (0x89 PNG)
31
+ within the binary portion to correctly extract the image.
32
+
33
+ Args:
34
+ replay: The raw string response from the printer, which may include text and binary data
35
+ file_name: The name of the file for which the thumbnail was retrieved
36
+
37
+ Returns:
38
+ A ThumbnailInfo instance populated with the image data if parsing is successful,
39
+ or None if the replay is invalid, "ok" is not found, or the PNG signature is missing
40
+ """
41
+ if not replay:
42
+ return None
43
+
44
+ try:
45
+ # Store the file name
46
+ self._file_name = file_name
47
+
48
+ # Find where the PNG data starts (after the "ok" text delimiter)
49
+ ok_index = replay.find('ok')
50
+ if ok_index == -1:
51
+ print("ThumbnailInfo: No 'ok' found in response")
52
+ return None
53
+
54
+ # Skip the 'ok' text and any immediately following control characters
55
+ # The actual binary data starts after "ok"
56
+ binary_start_index = ok_index + 2 # Length of "ok"
57
+ raw_binary_data = replay[binary_start_index:]
58
+
59
+ # Convert the extracted string part (assumed to be binary) into bytes
60
+ # The printer sends binary data as part of a string reply
61
+ binary_buffer = raw_binary_data.encode('latin1') # Use latin1 to preserve byte values
62
+
63
+ # Look for the PNG file signature (89 50 4E 47 0D 0A 1A 0A) in the buffer
64
+ # to correctly identify the start of the actual image data
65
+ png_signature = b'\x89\x50\x4E\x47\x0D\x0A\x1A\x0A'
66
+ png_start = binary_buffer.find(png_signature)
67
+
68
+ if png_start >= 0:
69
+ # Slice the buffer from the start of the PNG signature to get the clean image data
70
+ self._image_data = binary_buffer[png_start:]
71
+ return self
72
+ else:
73
+ print("ThumbnailInfo: No PNG signature found in binary data")
74
+ return None
75
+
76
+ except Exception as e:
77
+ print(f"ThumbnailInfo: Error parsing response: {e}")
78
+ return None
79
+
80
+ def get_image_data(self) -> Optional[str]:
81
+ """
82
+ Gets the raw thumbnail image data as a Base64 encoded string.
83
+
84
+ Returns:
85
+ A Base64 encoded string of the PNG image data, or None if no image data is available
86
+ """
87
+ if not self._image_data:
88
+ return None
89
+ return base64.b64encode(self._image_data).decode('ascii')
90
+
91
+ def get_image_bytes(self) -> Optional[bytes]:
92
+ """
93
+ Gets the raw thumbnail image data as bytes.
94
+
95
+ Returns:
96
+ The PNG image data as bytes, or None if no image data is available
97
+ """
98
+ return self._image_data
99
+
100
+ def get_file_name(self) -> Optional[str]:
101
+ """
102
+ Gets the file name associated with this thumbnail.
103
+
104
+ Returns:
105
+ The file name string, or None if it was not set during parsing
106
+ """
107
+ return self._file_name
108
+
109
+ def to_base64_data_url(self) -> Optional[str]:
110
+ """
111
+ Converts the thumbnail image data to a Base64 data URL, suitable for embedding in web pages.
112
+
113
+ Returns:
114
+ A Base64 data URL string (e.g., "data:image/png;base64,..."), or None if no image data is available
115
+ """
116
+ if not self._image_data:
117
+ return None
118
+
119
+ base64_data = base64.b64encode(self._image_data).decode('ascii')
120
+ return f"data:image/png;base64,{base64_data}"
121
+
122
+ async def save_to_file(self, file_path: Optional[str] = None) -> bool:
123
+ """
124
+ Saves the thumbnail image data to a file.
125
+
126
+ If no file_path is provided, it attempts to generate a filename using the
127
+ original filename (stored during from_replay) with a ".png" extension.
128
+
129
+ Args:
130
+ file_path: Optional. The full path (including filename and extension) where the thumbnail should be saved.
131
+ If not provided, a filename is generated from self._file_name
132
+
133
+ Returns:
134
+ True if the file was saved successfully, False otherwise
135
+ """
136
+ if not self._image_data:
137
+ print("ThumbnailInfo: No image data to save")
138
+ return False
139
+
140
+ try:
141
+ # If no file path is provided, generate one based on the original filename
142
+ if not file_path and self._file_name:
143
+ # Extract the filename without extension
144
+ path_obj = Path(self._file_name)
145
+ base_name = path_obj.stem
146
+ file_path = f"{base_name}.png"
147
+
148
+ if not file_path:
149
+ print("ThumbnailInfo: No file path provided and no filename to generate one from")
150
+ return False
151
+
152
+ # Write the bytes to file
153
+ with open(file_path, 'wb') as f:
154
+ f.write(self._image_data)
155
+
156
+ print(f"ThumbnailInfo: Saved thumbnail to {file_path}")
157
+ return True
158
+
159
+ except Exception as e:
160
+ print(f"ThumbnailInfo: Error saving thumbnail to file: {e}")
161
+ return False
162
+
163
+ def save_to_file_sync(self, file_path: Optional[str] = None) -> bool:
164
+ """
165
+ Synchronous version of save_to_file for compatibility.
166
+
167
+ Args:
168
+ file_path: Optional. The full path where the thumbnail should be saved
169
+
170
+ Returns:
171
+ True if the file was saved successfully, False otherwise
172
+ """
173
+ if not self._image_data:
174
+ print("ThumbnailInfo: No image data to save")
175
+ return False
176
+
177
+ try:
178
+ # If no file path is provided, generate one based on the original filename
179
+ if not file_path and self._file_name:
180
+ # Extract the filename without extension
181
+ path_obj = Path(self._file_name)
182
+ base_name = path_obj.stem
183
+ file_path = f"{base_name}.png"
184
+
185
+ if not file_path:
186
+ print("ThumbnailInfo: No file path provided and no filename to generate one from")
187
+ return False
188
+
189
+ # Write the bytes to file
190
+ with open(file_path, 'wb') as f:
191
+ f.write(self._image_data)
192
+
193
+ print(f"ThumbnailInfo: Saved thumbnail to {file_path}")
194
+ return True
195
+
196
+ except Exception as e:
197
+ print(f"ThumbnailInfo: Error saving thumbnail to file: {e}")
198
+ return False
199
+
200
+ def get_image_size(self) -> tuple[int, int]:
201
+ """
202
+ Gets the image dimensions by parsing the PNG header.
203
+
204
+ Returns:
205
+ A tuple of (width, height) in pixels, or (0, 0) if parsing fails
206
+ """
207
+ if not self._image_data or len(self._image_data) < 24:
208
+ return (0, 0)
209
+
210
+ try:
211
+ # PNG width and height are stored at bytes 16-23 in big-endian format
212
+ width = int.from_bytes(self._image_data[16:20], byteorder='big')
213
+ height = int.from_bytes(self._image_data[20:24], byteorder='big')
214
+ return (width, height)
215
+ except Exception:
216
+ return (0, 0)
217
+
218
+ def has_image_data(self) -> bool:
219
+ """
220
+ Checks if this instance contains valid image data.
221
+
222
+ Returns:
223
+ True if image data is available, False otherwise
224
+ """
225
+ return self._image_data is not None and len(self._image_data) > 0
226
+
227
+ def __str__(self) -> str:
228
+ """String representation of the thumbnail info."""
229
+ if self._image_data:
230
+ width, height = self.get_image_size()
231
+ size_str = f"{len(self._image_data)} bytes, {width}x{height}px"
232
+ else:
233
+ size_str = "no data"
234
+
235
+ return f"ThumbnailInfo(file='{self._file_name}', {size_str})"
236
+
237
+ def __repr__(self) -> str:
238
+ """Detailed string representation for debugging."""
239
+ return (f"ThumbnailInfo("
240
+ f"file_name='{self._file_name}', "
241
+ f"has_data={self.has_image_data()}, "
242
+ f"size={len(self._image_data) if self._image_data else 0} bytes)")
@@ -0,0 +1,448 @@
1
+ """
2
+ Low-level TCP client for communicating with FlashForge 3D printers.
3
+
4
+ This module provides the foundational TCP communication layer, managing socket connections,
5
+ sending raw commands, handling responses, and maintaining keep-alive connections.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ from typing import List, Optional
11
+
12
+ from .gcode.gcodes import GCodes
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class FlashForgeTcpClient:
18
+ """
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.
28
+ """
29
+
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
+ """
39
+ self.hostname = hostname
40
+ self.port = 8899
41
+ """The default TCP port used for connecting to FlashForge printers."""
42
+
43
+ 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
+
53
+ self._keep_alive_cancellation_token = False
54
+ """Token to signal cancellation of the keep-alive loop."""
55
+
56
+ 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
+ self._socket_lock = asyncio.Lock()
63
+ """Lock to ensure only one command is sent at a time."""
64
+
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
72
+
73
+ 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
+ """
82
+ if self._keep_alive_task and not self._keep_alive_task.done():
83
+ return # Already running
84
+
85
+ self._keep_alive_cancellation_token = False
86
+
87
+ async def run_keep_alive():
88
+ try:
89
+ while not self._keep_alive_cancellation_token:
90
+ # logger.debug("KeepAlive")
91
+ result = await self.send_command_async(GCodes.CMD_PRINT_STATUS)
92
+ 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}")
96
+ break
97
+
98
+ if self._keep_alive_errors > 0:
99
+ self._keep_alive_errors -= 1 # Move back to 0 errors with each "good" keep-alive
100
+
101
+ # Increase keep alive timeout based on error count
102
+ await asyncio.sleep(5.0 + self._keep_alive_errors * 1.0)
103
+
104
+ except Exception as error:
105
+ logger.error(f"KeepAlive encountered an exception: {error}")
106
+
107
+ self._keep_alive_task = asyncio.create_task(run_keep_alive())
108
+
109
+ 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
+ """
116
+ if logout:
117
+ try:
118
+ await self.send_command_async(GCodes.CMD_LOGOUT) # Release control
119
+ except Exception:
120
+ pass # Ignore errors during logout
121
+
122
+ self._keep_alive_cancellation_token = True
123
+
124
+ if self._keep_alive_task and not self._keep_alive_task.done():
125
+ self._keep_alive_task.cancel()
126
+ try:
127
+ await self._keep_alive_task
128
+ except asyncio.CancelledError:
129
+ pass
130
+
131
+ logger.info("Keep-alive stopped.")
132
+
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]:
143
+ """
144
+ 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
156
+ """
157
+ async with self._socket_lock:
158
+ self._socket_busy = True
159
+
160
+ logger.debug(f"sendCommand: {cmd}")
161
+ try:
162
+ await self._check_socket()
163
+
164
+ # Write command
165
+ self._writer.write((cmd + '\n').encode('ascii'))
166
+ await self._writer.drain()
167
+
168
+ # Receive response
169
+ reply = await self._receive_multi_line_replay_async(cmd)
170
+
171
+ if reply is not None:
172
+ # logger.debug(f"Received reply for command: {reply}")
173
+ 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
179
+
180
+ except Exception as error:
181
+ logger.error(f"Error while sending command: {error}")
182
+ return None
183
+ finally:
184
+ self._socket_busy = False
185
+
186
+ async def _wait_until_socket_available(self) -> None:
187
+ """
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)
194
+ """
195
+ max_wait_time = 10.0 # 10 seconds
196
+ start_time = asyncio.get_event_loop().time()
197
+
198
+ while self._socket_busy and (asyncio.get_event_loop().time() - start_time < max_wait_time):
199
+ await asyncio.sleep(0.1)
200
+
201
+ if self._socket_busy:
202
+ raise TimeoutError("Socket remained busy for too long, timing out")
203
+
204
+ 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()")
211
+ fix = False
212
+
213
+ if self._writer is None or self._reader is None:
214
+ fix = True
215
+ # logger.debug("TcpPrinterClient socket is null")
216
+ elif self._writer.is_closing():
217
+ fix = True
218
+ # logger.debug("TcpPrinterClient socket is closed")
219
+
220
+ if not fix:
221
+ return
222
+
223
+ logger.warning("Reconnecting to TCP socket...")
224
+ await self._connect()
225
+ await self.start_keep_alive() # Start this here rather than in Connect()
226
+
227
+ 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()")
234
+ try:
235
+ self._reader, self._writer = await asyncio.wait_for(
236
+ asyncio.open_connection(self.hostname, self.port),
237
+ timeout=self.timeout
238
+ )
239
+ except Exception as error:
240
+ logger.error(f"Failed to connect to {self.hostname}:{self.port}: {error}")
241
+ raise
242
+
243
+ 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()")
250
+ await self.stop_keep_alive()
251
+ if self._writer:
252
+ self._writer.close()
253
+ try:
254
+ await self._writer.wait_closed()
255
+ except Exception:
256
+ pass
257
+ self._reader = None
258
+ self._writer = None
259
+
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
+
279
+ if not self._reader:
280
+ # logger.error("Reader is null, cannot receive reply.")
281
+ return None
282
+
283
+ answer = bytearray()
284
+
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
304
+
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
329
+ break
330
+
331
+ except Exception as e:
332
+ logger.error(f"Error receiving multi-line command reply: {e}")
333
+ return None
334
+
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.")
341
+ 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
+
351
+ if not result:
352
+ logger.error("ReceiveMultiLineReplayAsync received an empty response.")
353
+ return None
354
+ return result
355
+
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
+ 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
376
+ except Exception:
377
+ return False
378
+
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
+ """
389
+ response = await self.send_command_async(GCodes.CMD_LIST_LOCAL_FILES)
390
+ if response:
391
+ return self._parse_file_list_response(response)
392
+ return []
393
+
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('::')
408
+
409
+ # Extract file paths
410
+ file_paths = []
411
+ 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)
427
+
428
+ return file_paths
429
+
430
+ 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
+ """
436
+ try:
437
+ logger.info("TcpPrinterClient closing socket")
438
+ await self.stop_keep_alive(logout=True) # Stop keep-alive timer and send logout command
439
+ if self._writer:
440
+ self._writer.close()
441
+ try:
442
+ await self._writer.wait_closed()
443
+ except Exception:
444
+ pass
445
+ self._reader = None
446
+ self._writer = None
447
+ except Exception as error:
448
+ logger.error(f"Error during dispose: {error}")