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
@@ -14,7 +14,7 @@ logger = logging.getLogger(__name__)
14
14
  class TempData:
15
15
  """
16
16
  Represents temperature data for a single component (e.g., extruder or bed).
17
-
17
+
18
18
  Includes current temperature and target (set) temperature.
19
19
  Temperatures are stored as strings but can be retrieved as numbers.
20
20
  """
@@ -22,23 +22,25 @@ class TempData:
22
22
  def __init__(self, data: str) -> None:
23
23
  """
24
24
  Create a TempData instance by parsing a temperature string.
25
-
25
+
26
26
  The input string can be in the format "current/set" (e.g., "210/210")
27
27
  or just "current" (e.g., "25") if the target temperature is not specified.
28
28
  It also handles and removes a trailing "/0.0" if present from some printer firmwares.
29
29
  All temperatures are rounded to the nearest integer.
30
-
30
+
31
31
  Args:
32
32
  data: The temperature data string (e.g., "210/210", "25", "60/60/0.0")
33
33
  """
34
34
  # Handle potential formatting issues by removing any non-relevant part
35
- data = data.replace('/0.0', '') # Remove trailing '/0.0' if exists
35
+ data = data.replace("/0.0", "") # Remove trailing '/0.0' if exists
36
36
 
37
37
  if "/" in data:
38
38
  # Replay has current/set temps
39
- split_temps = data.split('/')
39
+ split_temps = data.split("/")
40
40
  self._current = self._parse_temp_data(split_temps[0].strip())
41
- self._set = self._parse_temp_data(split_temps[1].strip()) if len(split_temps) > 1 else None
41
+ self._set = (
42
+ self._parse_temp_data(split_temps[1].strip()) if len(split_temps) > 1 else None
43
+ )
42
44
  else:
43
45
  # Replay only has current temp (when printer is idle)
44
46
  self._current = self._parse_temp_data(data)
@@ -47,22 +49,22 @@ class TempData:
47
49
  def _parse_temp_data(self, data: str) -> str:
48
50
  """
49
51
  Parse a raw temperature string value, round it, and return as string.
50
-
52
+
51
53
  Args:
52
54
  data: The raw temperature string (e.g., "210.5", "60")
53
-
55
+
54
56
  Returns:
55
57
  The rounded temperature as a string
56
58
  """
57
59
  if "." in data:
58
- data = data.split('.')[0].strip() # Truncate decimal part before rounding
60
+ data = data.split(".")[0].strip() # Truncate decimal part before rounding
59
61
  temp = round(float(data))
60
62
  return str(temp)
61
63
 
62
64
  def get_full(self) -> str:
63
65
  """
64
66
  Get the full temperature string, including current and set temperatures.
65
-
67
+
66
68
  Returns:
67
69
  A string in the format "current/set" or just "current" if set temperature is not available
68
70
  """
@@ -73,7 +75,7 @@ class TempData:
73
75
  def get_current(self) -> int:
74
76
  """
75
77
  Get the current temperature as a number.
76
-
78
+
77
79
  Returns:
78
80
  The current temperature in Celsius
79
81
  """
@@ -82,7 +84,7 @@ class TempData:
82
84
  def get_set(self) -> int:
83
85
  """
84
86
  Get the target (set) temperature as a number.
85
-
87
+
86
88
  Returns:
87
89
  The set temperature in Celsius, or 0 if not set
88
90
  """
@@ -92,32 +94,32 @@ class TempData:
92
94
  class TempInfo:
93
95
  """
94
96
  Represents the temperature information for the printer's extruder and bed.
95
-
97
+
96
98
  This data is typically parsed from the response of an M105 G-code command,
97
99
  which reports the current and target temperatures.
98
100
  """
99
101
 
100
102
  def __init__(self) -> None:
101
103
  """Initialize empty temperature info."""
102
- self._extruder_temp: Optional[TempData] = None
104
+ self._extruder_temp: TempData | None = None
103
105
  """Temperature data for the extruder."""
104
106
 
105
- self._bed_temp: Optional[TempData] = None
107
+ self._bed_temp: TempData | None = None
106
108
  """Temperature data for the print bed."""
107
109
 
108
- def from_replay(self, replay: str) -> Optional['TempInfo']:
110
+ def from_replay(self, replay: str) -> Optional["TempInfo"]:
109
111
  """
110
112
  Parse a raw string replay from M105 command to populate temperature info.
111
-
113
+
112
114
  The M105 response format is usually a single line (after the "ok" or command echo)
113
115
  containing temperature segments like "T0:25/0" or "T:210/210 B:60/60".
114
116
  This method splits the relevant line by spaces and then parses each segment.
115
117
  It looks for segments starting with "T0:", "T):", or "T:" for extruder temperature,
116
118
  and "B:" for bed temperature.
117
-
119
+
118
120
  Args:
119
121
  replay: The raw multi-line string response from the printer
120
-
122
+
121
123
  Returns:
122
124
  The populated TempInfo instance, or None if parsing fails
123
125
  """
@@ -125,29 +127,24 @@ class TempInfo:
125
127
  return None
126
128
 
127
129
  try:
128
- data = replay.split('\n')
129
- if len(data) <= 1:
130
- logger.error(f"TempInfo replay has invalid data: {data}")
130
+ lines = [line.strip() for line in replay.replace("\r", "\n").split("\n") if line.strip()]
131
+ if not lines:
132
+ logger.error("TempInfo replay has invalid data: %s", replay)
131
133
  return None
132
134
 
133
- # Relevant temperature data is usually on the second line (data[1])
134
- # e.g., "T0:25/0 B:28/0 @:0 B@:0" or "T:210/210 B:60/60"
135
- temp_data = data[1].split(' ')
136
135
  extruder_data_str = None
137
136
  bed_data_str = None
138
137
 
139
- # Parse each temperature segment
140
- for segment in temp_data:
141
- # Check for extruder temperature (T0, T, or T) for some printers)
142
- if segment.startswith('T0:'):
143
- extruder_data_str = segment.replace('T0:', '')
144
- elif segment.startswith('T):'): # Some printers might use T):
145
- extruder_data_str = segment.replace('T):', '')
146
- elif segment.startswith('T:'): # General case for T:
147
- extruder_data_str = segment.replace('T:', '')
148
- # Check for bed temperature
149
- elif segment.startswith('B:'):
150
- bed_data_str = segment.replace('B:', '')
138
+ for line in lines:
139
+ for segment in line.split(" "):
140
+ if segment.startswith("T0:"):
141
+ extruder_data_str = segment.replace("T0:", "")
142
+ elif segment.startswith("T):"):
143
+ extruder_data_str = segment.replace("T):", "")
144
+ elif segment.startswith("T:"):
145
+ extruder_data_str = segment.replace("T:", "")
146
+ elif segment.startswith("B:"):
147
+ bed_data_str = segment.replace("B:", "")
151
148
 
152
149
  # If we found extruder data, create TempData object
153
150
  if extruder_data_str:
@@ -160,8 +157,10 @@ class TempInfo:
160
157
  if bed_data_str:
161
158
  self._bed_temp = TempData(bed_data_str)
162
159
  else:
163
- logger.warning(f"No bed temperature found in replay data, defaulting to 0/0: {replay}")
164
- self._bed_temp = TempData('0/0') # Default if not present
160
+ logger.warning(
161
+ f"No bed temperature found in replay data, defaulting to 0/0: {replay}"
162
+ )
163
+ self._bed_temp = TempData("0/0") # Default if not present
165
164
 
166
165
  return self
167
166
 
@@ -170,19 +169,19 @@ class TempInfo:
170
169
  logger.error(f"Raw replay data: {replay}")
171
170
  return None
172
171
 
173
- def get_extruder_temp(self) -> Optional[TempData]:
172
+ def get_extruder_temp(self) -> TempData | None:
174
173
  """
175
174
  Get the extruder temperature data.
176
-
175
+
177
176
  Returns:
178
177
  A TempData object for the extruder, or None if not available
179
178
  """
180
179
  return self._extruder_temp
181
180
 
182
- def get_bed_temp(self) -> Optional[TempData]:
181
+ def get_bed_temp(self) -> TempData | None:
183
182
  """
184
183
  Get the print bed temperature data.
185
-
184
+
186
185
  Returns:
187
186
  A TempData object for the bed, or None if not available
188
187
  """
@@ -191,10 +190,10 @@ class TempInfo:
191
190
  def is_cooled(self) -> bool:
192
191
  """
193
192
  Check if both the bed and extruder are cooled down to relatively low temperatures.
194
-
193
+
195
194
  Bed temperature <= 40°C and extruder temperature <= 200°C (though 200 is still hot).
196
195
  Use with caution, as "cooled" here is relative and 200C is still very hot for an extruder.
197
-
196
+
198
197
  Returns:
199
198
  True if temperatures are at or below the defined thresholds, False otherwise
200
199
  """
@@ -205,10 +204,10 @@ class TempInfo:
205
204
  def are_temps_safe(self) -> bool:
206
205
  """
207
206
  Check if the current temperatures are within a generally safe operating range.
208
-
207
+
209
208
  Prevents overheating (extruder < 250°C, bed < 100°C).
210
209
  These are arbitrary "safe" limits and might need adjustment based on specific printer/material.
211
-
210
+
212
211
  Returns:
213
212
  True if temperatures are below the defined "safe" thresholds, False otherwise
214
213
  """
@@ -3,6 +3,8 @@ FlashForge Python API - Thumbnail Info Parser
3
3
 
4
4
  Handles the parsing, storage, and manipulation of 3D print file thumbnail images.
5
5
  """
6
+
7
+ import asyncio
6
8
  import base64
7
9
  from pathlib import Path
8
10
  from typing import Optional
@@ -11,29 +13,29 @@ from typing import Optional
11
13
  class ThumbnailInfo:
12
14
  """
13
15
  Handles the parsing, storage, and manipulation of 3D print file thumbnail images.
14
-
16
+
15
17
  Thumbnails are typically retrieved from the printer using a command like M662,
16
18
  which returns a mixed response containing text (e.g., "ok") followed by raw binary PNG data.
17
19
  This class provides methods to extract the PNG data, convert it to various formats, and save it to a file.
18
20
  """
19
21
 
20
- def __init__(self):
22
+ def __init__(self) -> None:
21
23
  """Initialize a new ThumbnailInfo instance."""
22
- self._image_data: Optional[bytes] = None
23
- self._file_name: Optional[str] = None
24
+ self._image_data: bytes | None = None
25
+ self._file_name: str | None = None
24
26
 
25
- def from_replay(self, replay: str, file_name: str) -> Optional['ThumbnailInfo']:
27
+ def from_replay(self, replay: str, file_name: str) -> Optional["ThumbnailInfo"]:
26
28
  """
27
29
  Parses thumbnail data from a raw printer response string.
28
-
30
+
29
31
  The method expects the response to contain an "ok" text delimiter, after which
30
32
  the binary PNG data begins. It searches for the PNG signature (0x89 PNG)
31
33
  within the binary portion to correctly extract the image.
32
-
34
+
33
35
  Args:
34
36
  replay: The raw string response from the printer, which may include text and binary data
35
37
  file_name: The name of the file for which the thumbnail was retrieved
36
-
38
+
37
39
  Returns:
38
40
  A ThumbnailInfo instance populated with the image data if parsing is successful,
39
41
  or None if the replay is invalid, "ok" is not found, or the PNG signature is missing
@@ -44,9 +46,24 @@ class ThumbnailInfo:
44
46
  try:
45
47
  # Store the file name
46
48
  self._file_name = file_name
49
+ if "Error: File not exists" in replay:
50
+ return None
51
+
52
+ binary_buffer = replay.encode("latin1")
53
+ magic_marker = b"\xA2\xA2\x2A\x2A"
54
+ magic_offset = binary_buffer.find(magic_marker)
55
+ if magic_offset >= 0 and len(binary_buffer) >= magic_offset + 8:
56
+ payload_length = int.from_bytes(
57
+ binary_buffer[magic_offset + 4 : magic_offset + 8],
58
+ byteorder="big",
59
+ )
60
+ payload_end = magic_offset + 8 + payload_length
61
+ if len(binary_buffer) >= payload_end:
62
+ self._image_data = binary_buffer[magic_offset + 8 : payload_end]
63
+ return self
47
64
 
48
65
  # Find where the PNG data starts (after the "ok" text delimiter)
49
- ok_index = replay.find('ok')
66
+ ok_index = replay.find("ok")
50
67
  if ok_index == -1:
51
68
  print("ThumbnailInfo: No 'ok' found in response")
52
69
  return None
@@ -58,11 +75,11 @@ class ThumbnailInfo:
58
75
 
59
76
  # Convert the extracted string part (assumed to be binary) into bytes
60
77
  # 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
78
+ binary_buffer = raw_binary_data.encode("latin1") # Use latin1 to preserve byte values
62
79
 
63
80
  # Look for the PNG file signature (89 50 4E 47 0D 0A 1A 0A) in the buffer
64
81
  # to correctly identify the start of the actual image data
65
- png_signature = b'\x89\x50\x4E\x47\x0D\x0A\x1A\x0A'
82
+ png_signature = b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a"
66
83
  png_start = binary_buffer.find(png_signature)
67
84
 
68
85
  if png_start >= 0:
@@ -77,59 +94,65 @@ class ThumbnailInfo:
77
94
  print(f"ThumbnailInfo: Error parsing response: {e}")
78
95
  return None
79
96
 
80
- def get_image_data(self) -> Optional[str]:
97
+ def get_image_data(self) -> str | None:
81
98
  """
82
99
  Gets the raw thumbnail image data as a Base64 encoded string.
83
-
100
+
84
101
  Returns:
85
102
  A Base64 encoded string of the PNG image data, or None if no image data is available
86
103
  """
87
104
  if not self._image_data:
88
105
  return None
89
- return base64.b64encode(self._image_data).decode('ascii')
106
+ return base64.b64encode(self._image_data).decode("ascii")
90
107
 
91
- def get_image_bytes(self) -> Optional[bytes]:
108
+ def get_image_bytes(self) -> bytes | None:
92
109
  """
93
110
  Gets the raw thumbnail image data as bytes.
94
-
111
+
95
112
  Returns:
96
113
  The PNG image data as bytes, or None if no image data is available
97
114
  """
98
115
  return self._image_data
99
116
 
100
- def get_file_name(self) -> Optional[str]:
117
+ def get_file_name(self) -> str | None:
101
118
  """
102
119
  Gets the file name associated with this thumbnail.
103
-
120
+
104
121
  Returns:
105
122
  The file name string, or None if it was not set during parsing
106
123
  """
107
124
  return self._file_name
108
125
 
109
- def to_base64_data_url(self) -> Optional[str]:
126
+ def to_base64_data_url(self) -> str | None:
110
127
  """
111
128
  Converts the thumbnail image data to a Base64 data URL, suitable for embedding in web pages.
112
-
129
+
113
130
  Returns:
114
131
  A Base64 data URL string (e.g., "data:image/png;base64,..."), or None if no image data is available
115
132
  """
116
133
  if not self._image_data:
117
134
  return None
118
135
 
119
- base64_data = base64.b64encode(self._image_data).decode('ascii')
136
+ base64_data = base64.b64encode(self._image_data).decode("ascii")
120
137
  return f"data:image/png;base64,{base64_data}"
121
138
 
122
- async def save_to_file(self, file_path: Optional[str] = None) -> bool:
139
+ @staticmethod
140
+ def _write_file_sync(file_path: str, data: bytes) -> None:
141
+ """Helper to write bytes to a file synchronously."""
142
+ with open(file_path, "wb") as f:
143
+ f.write(data)
144
+
145
+ async def save_to_file(self, file_path: str | None = None) -> bool:
123
146
  """
124
147
  Saves the thumbnail image data to a file.
125
-
148
+
126
149
  If no file_path is provided, it attempts to generate a filename using the
127
150
  original filename (stored during from_replay) with a ".png" extension.
128
-
151
+
129
152
  Args:
130
153
  file_path: Optional. The full path (including filename and extension) where the thumbnail should be saved.
131
154
  If not provided, a filename is generated from self._file_name
132
-
155
+
133
156
  Returns:
134
157
  True if the file was saved successfully, False otherwise
135
158
  """
@@ -149,9 +172,9 @@ class ThumbnailInfo:
149
172
  print("ThumbnailInfo: No file path provided and no filename to generate one from")
150
173
  return False
151
174
 
152
- # Write the bytes to file
153
- with open(file_path, 'wb') as f:
154
- f.write(self._image_data)
175
+ # Write the bytes to file in a separate thread to avoid blocking the event loop
176
+ loop = asyncio.get_running_loop()
177
+ await loop.run_in_executor(None, self._write_file_sync, file_path, self._image_data)
155
178
 
156
179
  print(f"ThumbnailInfo: Saved thumbnail to {file_path}")
157
180
  return True
@@ -160,13 +183,13 @@ class ThumbnailInfo:
160
183
  print(f"ThumbnailInfo: Error saving thumbnail to file: {e}")
161
184
  return False
162
185
 
163
- def save_to_file_sync(self, file_path: Optional[str] = None) -> bool:
186
+ def save_to_file_sync(self, file_path: str | None = None) -> bool:
164
187
  """
165
188
  Synchronous version of save_to_file for compatibility.
166
-
189
+
167
190
  Args:
168
191
  file_path: Optional. The full path where the thumbnail should be saved
169
-
192
+
170
193
  Returns:
171
194
  True if the file was saved successfully, False otherwise
172
195
  """
@@ -187,7 +210,7 @@ class ThumbnailInfo:
187
210
  return False
188
211
 
189
212
  # Write the bytes to file
190
- with open(file_path, 'wb') as f:
213
+ with open(file_path, "wb") as f:
191
214
  f.write(self._image_data)
192
215
 
193
216
  print(f"ThumbnailInfo: Saved thumbnail to {file_path}")
@@ -200,7 +223,7 @@ class ThumbnailInfo:
200
223
  def get_image_size(self) -> tuple[int, int]:
201
224
  """
202
225
  Gets the image dimensions by parsing the PNG header.
203
-
226
+
204
227
  Returns:
205
228
  A tuple of (width, height) in pixels, or (0, 0) if parsing fails
206
229
  """
@@ -209,8 +232,8 @@ class ThumbnailInfo:
209
232
 
210
233
  try:
211
234
  # 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')
235
+ width = int.from_bytes(self._image_data[16:20], byteorder="big")
236
+ height = int.from_bytes(self._image_data[20:24], byteorder="big")
214
237
  return (width, height)
215
238
  except Exception:
216
239
  return (0, 0)
@@ -218,7 +241,7 @@ class ThumbnailInfo:
218
241
  def has_image_data(self) -> bool:
219
242
  """
220
243
  Checks if this instance contains valid image data.
221
-
244
+
222
245
  Returns:
223
246
  True if image data is available, False otherwise
224
247
  """
@@ -236,7 +259,9 @@ class ThumbnailInfo:
236
259
 
237
260
  def __repr__(self) -> str:
238
261
  """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)")
262
+ return (
263
+ f"ThumbnailInfo("
264
+ f"file_name='{self._file_name}', "
265
+ f"has_data={self.has_image_data()}, "
266
+ f"size={len(self._image_data) if self._image_data else 0} bytes)"
267
+ )