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.
- flashforge/__init__.py +188 -0
- flashforge/api/__init__.py +17 -0
- flashforge/api/constants/__init__.py +10 -0
- flashforge/api/constants/commands.py +10 -0
- flashforge/api/constants/endpoints.py +11 -0
- flashforge/api/controls/__init__.py +16 -0
- flashforge/api/controls/control.py +357 -0
- flashforge/api/controls/files.py +171 -0
- flashforge/api/controls/info.py +292 -0
- flashforge/api/controls/job_control.py +561 -0
- flashforge/api/controls/temp_control.py +89 -0
- flashforge/api/filament/__init__.py +7 -0
- flashforge/api/filament/filament.py +39 -0
- flashforge/api/misc/__init__.py +8 -0
- flashforge/api/misc/scientific_notation.py +28 -0
- flashforge/api/misc/temperature.py +42 -0
- flashforge/api/network/__init__.py +10 -0
- flashforge/api/network/fnet_code.py +15 -0
- flashforge/api/network/utils.py +55 -0
- flashforge/client.py +381 -0
- flashforge/discovery/__init__.py +12 -0
- flashforge/discovery/discovery.py +388 -0
- flashforge/models/__init__.py +50 -0
- flashforge/models/machine_info.py +247 -0
- flashforge/models/responses.py +123 -0
- flashforge/tcp/__init__.py +41 -0
- flashforge/tcp/ff_client.py +587 -0
- flashforge/tcp/gcode/__init__.py +11 -0
- flashforge/tcp/gcode/gcode_controller.py +296 -0
- flashforge/tcp/gcode/gcodes.py +93 -0
- flashforge/tcp/parsers/__init__.py +27 -0
- flashforge/tcp/parsers/endstop_status.py +273 -0
- flashforge/tcp/parsers/location_info.py +68 -0
- flashforge/tcp/parsers/print_status.py +182 -0
- flashforge/tcp/parsers/printer_info.py +154 -0
- flashforge/tcp/parsers/temp_info.py +217 -0
- flashforge/tcp/parsers/thumbnail_info.py +242 -0
- flashforge/tcp/tcp_client.py +448 -0
- flashforge_python_api-1.0.0.dist-info/METADATA +123 -0
- flashforge_python_api-1.0.0.dist-info/RECORD +43 -0
- flashforge_python_api-1.0.0.dist-info/WHEEL +4 -0
- flashforge_python_api-1.0.0.dist-info/entry_points.txt +2 -0
- 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}")
|