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.
- flashforge/__init__.py +70 -29
- flashforge/api/__init__.py +1 -0
- flashforge/api/constants/__init__.py +1 -0
- flashforge/api/constants/commands.py +1 -0
- flashforge/api/constants/endpoints.py +7 -0
- flashforge/api/controls/__init__.py +1 -0
- flashforge/api/controls/control.py +64 -61
- flashforge/api/controls/files.py +28 -26
- flashforge/api/controls/info.py +84 -85
- flashforge/api/controls/job_control.py +120 -82
- flashforge/api/controls/temp_control.py +14 -11
- flashforge/api/misc/__init__.py +1 -1
- flashforge/api/network/__init__.py +2 -1
- flashforge/api/network/utils.py +7 -8
- flashforge/client.py +185 -64
- flashforge/discovery/__init__.py +30 -3
- flashforge/discovery/discovery.py +641 -308
- flashforge/models/__init__.py +11 -10
- flashforge/models/machine_info.py +225 -100
- flashforge/models/responses.py +103 -43
- flashforge/tcp/__init__.py +30 -17
- flashforge/tcp/a3_client.py +408 -0
- flashforge/tcp/a4_client.py +300 -0
- flashforge/tcp/ff_client.py +93 -90
- flashforge/tcp/gcode/__init__.py +4 -2
- flashforge/tcp/gcode/a3_gcode_controller.py +109 -0
- flashforge/tcp/gcode/gcode_controller.py +48 -36
- flashforge/tcp/gcode/gcodes.py +7 -1
- flashforge/tcp/parsers/__init__.py +11 -11
- flashforge/tcp/parsers/endstop_status.py +103 -132
- flashforge/tcp/parsers/location_info.py +26 -13
- flashforge/tcp/parsers/print_status.py +49 -56
- flashforge/tcp/parsers/printer_info.py +38 -48
- flashforge/tcp/parsers/temp_info.py +46 -47
- flashforge/tcp/parsers/thumbnail_info.py +65 -40
- flashforge/tcp/tcp_client.py +296 -293
- flashforge_python_api-1.2.0.dist-info/METADATA +133 -0
- flashforge_python_api-1.2.0.dist-info/RECORD +46 -0
- {flashforge_python_api-1.0.2.dist-info → flashforge_python_api-1.2.0.dist-info}/WHEEL +1 -1
- flashforge_python_api-1.0.2.dist-info/METADATA +0 -284
- flashforge_python_api-1.0.2.dist-info/RECORD +0 -43
- {flashforge_python_api-1.0.2.dist-info → flashforge_python_api-1.2.0.dist-info}/entry_points.txt +0 -0
- {flashforge_python_api-1.0.2.dist-info → flashforge_python_api-1.2.0.dist-info}/licenses/LICENSE +0 -0
flashforge/tcp/tcp_client.py
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
20
|
-
|
|
21
|
-
This class manages the socket connection,
|
|
22
|
-
and
|
|
23
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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)
|
|
86
|
+
await self.send_command_async(GCodes.CMD_LOGOUT)
|
|
119
87
|
except Exception:
|
|
120
|
-
pass
|
|
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
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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(
|
|
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
|
|
137
|
+
async def upload_file(self, local_file_path: str, remote_file_name: str | None = None) -> bool:
|
|
187
138
|
"""
|
|
188
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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()
|
|
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(
|
|
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) ->
|
|
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
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
336
|
-
|
|
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("
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
380
|
-
"""
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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) ->
|
|
395
|
-
"""
|
|
396
|
-
|
|
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(
|
|
413
|
-
if data_index
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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)
|
|
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(
|
|
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
|
+
)
|