flashforge-python-api 1.1.0__py3-none-any.whl → 1.2.1__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 CHANGED
@@ -99,9 +99,13 @@ from .tcp import (
99
99
  A3GCodeController,
100
100
  A3PrinterInfo,
101
101
  A3Thumbnail,
102
+ A4BuildVolume,
103
+ A4FileEntry,
104
+ A4PrinterInfo,
102
105
  Endstop,
103
106
  EndstopStatus,
104
107
  FlashForgeA3Client,
108
+ FlashForgeA4Client,
105
109
  FlashForgeTcpClient,
106
110
  FlashForgeTcpClientOptions,
107
111
  GCodeController,
@@ -123,7 +127,7 @@ from .tcp import (
123
127
  )
124
128
 
125
129
  FiveMClient = FlashForgeClient
126
- __version__ = "1.1.0"
130
+ __version__ = "1.1.1"
127
131
  __author__ = "FlashForge Python API Contributors"
128
132
  __email__ = "notghosttypes@gmail.com"
129
133
  __description__ = "Python library for controlling FlashForge 3D printers"
@@ -167,6 +171,7 @@ __all__ = [
167
171
  "FlashForgeTcpClient",
168
172
  "FlashForgeTcpClientOptions",
169
173
  "FlashForgeA3Client",
174
+ "FlashForgeA4Client",
170
175
  "PrinterInfo",
171
176
  "TempInfo",
172
177
  "TempData",
@@ -185,6 +190,9 @@ __all__ = [
185
190
  "A3PrinterInfo",
186
191
  "A3FileEntry",
187
192
  "A3Thumbnail",
193
+ "A4BuildVolume",
194
+ "A4PrinterInfo",
195
+ "A4FileEntry",
188
196
  # Discovery classes
189
197
  "PrinterDiscovery",
190
198
  "DiscoveredPrinter",
@@ -1,3 +1,9 @@
1
+ from typing import Final
2
+
3
+
4
+ CAMERA_STREAM_PORT: Final[int] = 8080
5
+
6
+
1
7
  class Endpoints:
2
8
  """
3
9
  Endpoints for the "new" HTTP API
@@ -4,12 +4,10 @@ FlashForge Python API - Control Module
4
4
 
5
5
  from typing import TYPE_CHECKING, Any
6
6
 
7
- import aiohttp
8
-
9
7
  from ...models.responses import FilamentArgs
10
8
  from ..constants.commands import Commands
11
9
  from ..constants.endpoints import Endpoints
12
- from ..network.utils import NetworkUtils
10
+ from ..network.utils import NetworkUtils, json_from_response
13
11
 
14
12
  if TYPE_CHECKING:
15
13
  from ...client import FlashForgeClient
@@ -239,26 +237,16 @@ class Control:
239
237
  try:
240
238
  await self.client.is_http_client_busy()
241
239
 
242
- async with aiohttp.ClientSession() as session:
243
- async with session.post(
244
- self.client.get_endpoint(Endpoints.CONTROL),
245
- json=payload,
246
- headers={"Content-Type": "application/json"},
247
- ) as response:
248
- # Fix for FlashForge printer's malformed Content-Type header
249
- # Some printers return "appliation/json" instead of "application/json"
250
- try:
251
- data = await response.json()
252
- except aiohttp.ContentTypeError:
253
- # Fallback: manually parse as JSON if Content-Type is malformed
254
- text = await response.text()
255
- import json
256
-
257
- data = json.loads(text)
258
-
259
- print(f"Command reply: {data}")
260
-
261
- return NetworkUtils.is_ok(data)
240
+ session = await self.client.get_http_session()
241
+ async with session.post(
242
+ self.client.get_endpoint(Endpoints.CONTROL),
243
+ json=payload,
244
+ headers={"Content-Type": "application/json"},
245
+ ) as response:
246
+ data = await json_from_response(response)
247
+ print(f"Command reply: {data}")
248
+
249
+ return NetworkUtils.is_ok(data)
262
250
 
263
251
  except Exception as e:
264
252
  print(f"Error in send_control_command: {e}")
@@ -5,13 +5,12 @@ FlashForge Python API - Files Module
5
5
  import base64
6
6
  from typing import TYPE_CHECKING
7
7
 
8
- import aiohttp
9
8
  from pydantic import ValidationError
10
9
 
11
10
  from ...models.machine_info import FFGcodeFileEntry
12
11
  from ...models.responses import GCodeListResponse, ThumbnailResponse
13
12
  from ..constants.endpoints import Endpoints
14
- from ..network.utils import NetworkUtils
13
+ from ..network.utils import NetworkUtils, json_from_response
15
14
 
16
15
  if TYPE_CHECKING:
17
16
  from ...client import FlashForgeClient
@@ -65,74 +64,65 @@ class Files:
65
64
  payload = {"serialNumber": self.client.serial_number, "checkCode": self.client.check_code}
66
65
 
67
66
  try:
68
- async with aiohttp.ClientSession() as session:
69
- async with session.post(
70
- self.client.get_endpoint(Endpoints.GCODE_LIST),
71
- json=payload,
72
- headers={"Content-Type": "application/json"},
73
- ) as response:
74
- if response.status != 200:
75
- return []
76
-
77
- # Fix for FlashForge printer's malformed Content-Type header
78
- # Some printers return "appliation/json" instead of "application/json"
79
- try:
80
- data = await response.json()
81
- except aiohttp.ContentTypeError:
82
- # Fallback: manually parse as JSON if Content-Type is malformed
83
- text = await response.text()
84
- import json
85
-
86
- data = json.loads(text)
87
-
88
- if not NetworkUtils.is_ok(data):
89
- print(f"Error retrieving file list: {NetworkUtils.get_error_message(data)}")
90
- return []
91
-
92
- # Parse the response using GCodeListResponse
93
- try:
94
- result = GCodeListResponse(**data)
95
- except ValidationError:
96
- raw_list = data.get("gcodeList", [])
97
- if isinstance(raw_list, list):
98
- entries: list[FFGcodeFileEntry] = []
99
- for file_name in raw_list:
100
- if isinstance(file_name, str):
101
- entries.append(
102
- FFGcodeFileEntry(
103
- gcodeFileName=file_name,
104
- printingTime=0,
105
- )
106
- )
107
- return entries
108
- return []
109
-
110
- # AD5X and newer printers provide detailed info in gcodeListDetail
111
- if result.gcode_list_detail and len(result.gcode_list_detail) > 0:
112
- return result.gcode_list_detail
113
-
114
- # Fallback for older printers using gcodeList
115
- if result.gcode_list and len(result.gcode_list) > 0:
116
- # Check if it's a list of strings or already FFGcodeFileEntry objects
117
- first_item = result.gcode_list[0]
118
-
119
- if isinstance(first_item, str):
120
- # Convert string array to FFGcodeFileEntry objects
121
- return [
122
- FFGcodeFileEntry(gcodeFileName=file_name, printingTime=0)
123
- for file_name in result.gcode_list
124
- if isinstance(file_name, str)
125
- ]
126
- elif isinstance(first_item, FFGcodeFileEntry):
127
- # Already FFGcodeFileEntry objects - need explicit type narrowing
128
- return [
129
- item
130
- for item in result.gcode_list
131
- if isinstance(item, FFGcodeFileEntry)
132
- ]
67
+ session = await self.client.get_http_session()
68
+ async with session.post(
69
+ self.client.get_endpoint(Endpoints.GCODE_LIST),
70
+ json=payload,
71
+ headers={"Content-Type": "application/json"},
72
+ ) as response:
73
+ if response.status != 200:
74
+ return []
133
75
 
76
+ data = await json_from_response(response)
77
+
78
+ if not NetworkUtils.is_ok(data):
79
+ print(f"Error retrieving file list: {NetworkUtils.get_error_message(data)}")
134
80
  return []
135
81
 
82
+ # Parse the response using GCodeListResponse
83
+ try:
84
+ result = GCodeListResponse(**data)
85
+ except ValidationError:
86
+ raw_list = data.get("gcodeList", [])
87
+ if isinstance(raw_list, list):
88
+ entries: list[FFGcodeFileEntry] = []
89
+ for file_name in raw_list:
90
+ if isinstance(file_name, str):
91
+ entries.append(
92
+ FFGcodeFileEntry(
93
+ gcodeFileName=file_name,
94
+ printingTime=0,
95
+ )
96
+ )
97
+ return entries
98
+ return []
99
+
100
+ # AD5X and newer printers provide detailed info in gcodeListDetail
101
+ if result.gcode_list_detail and len(result.gcode_list_detail) > 0:
102
+ return result.gcode_list_detail
103
+
104
+ # Fallback for older printers using gcodeList
105
+ if result.gcode_list and len(result.gcode_list) > 0:
106
+ # Check if it's a list of strings or already FFGcodeFileEntry objects
107
+ first_item = result.gcode_list[0]
108
+
109
+ if isinstance(first_item, str):
110
+ # Convert string array to FFGcodeFileEntry objects
111
+ return [
112
+ FFGcodeFileEntry(gcodeFileName=file_name, printingTime=0)
113
+ for file_name in result.gcode_list
114
+ if isinstance(file_name, str)
115
+ ]
116
+ elif isinstance(first_item, FFGcodeFileEntry):
117
+ # Already FFGcodeFileEntry objects - need explicit type narrowing
118
+ return [
119
+ item
120
+ for item in result.gcode_list
121
+ if isinstance(item, FFGcodeFileEntry)
122
+ ]
123
+
124
+ return []
125
+
136
126
  except Exception as err:
137
127
  print(f"GetRecentFileList error: {err}")
138
128
  return []
@@ -156,33 +146,24 @@ class Files:
156
146
  }
157
147
 
158
148
  try:
159
- async with aiohttp.ClientSession() as session:
160
- async with session.post(
161
- self.client.get_endpoint(Endpoints.GCODE_THUMB),
162
- json=payload,
163
- headers={"Content-Type": "application/json"},
164
- ) as response:
165
- if response.status != 200:
166
- return None
167
-
168
- # Fix for FlashForge printer's malformed Content-Type header
169
- # Some printers return "appliation/json" instead of "application/json"
170
- try:
171
- data = await response.json()
172
- except aiohttp.ContentTypeError:
173
- # Fallback: manually parse as JSON if Content-Type is malformed
174
- text = await response.text()
175
- import json
176
-
177
- data = json.loads(text)
178
-
179
- if NetworkUtils.is_ok(data):
180
- # Parse response and return decoded image bytes
181
- result = ThumbnailResponse(**data)
182
- return base64.b64decode(result.image_data)
183
- else:
184
- print(f"Error retrieving thumbnail: {NetworkUtils.get_error_message(data)}")
185
- return None
149
+ session = await self.client.get_http_session()
150
+ async with session.post(
151
+ self.client.get_endpoint(Endpoints.GCODE_THUMB),
152
+ json=payload,
153
+ headers={"Content-Type": "application/json"},
154
+ ) as response:
155
+ if response.status != 200:
156
+ return None
157
+
158
+ data = await json_from_response(response)
159
+
160
+ if NetworkUtils.is_ok(data):
161
+ # Parse response and return decoded image bytes
162
+ result = ThumbnailResponse(**data)
163
+ return base64.b64decode(result.image_data)
164
+ else:
165
+ print(f"Error retrieving thumbnail: {NetworkUtils.get_error_message(data)}")
166
+ return None
186
167
 
187
168
  except Exception as err:
188
169
  print(f"GetGcodeThumbnail error: {err}")
@@ -5,11 +5,10 @@ FlashForge Python API - Info Module
5
5
  from datetime import datetime, timedelta
6
6
  from typing import TYPE_CHECKING
7
7
 
8
- import aiohttp
9
-
10
8
  from ...models.machine_info import FFMachineInfo, MachineState, Temperature
11
9
  from ...models.responses import DetailResponse, FFPrinterDetail
12
10
  from ..constants.endpoints import Endpoints
11
+ from ..network.utils import json_from_response
13
12
 
14
13
  if TYPE_CHECKING:
15
14
  from ...client import FlashForgeClient
@@ -262,29 +261,18 @@ class Info:
262
261
  payload = {"serialNumber": self.client.serial_number, "checkCode": self.client.check_code}
263
262
 
264
263
  try:
265
- async with aiohttp.ClientSession() as session:
266
- async with session.post(
267
- self.client.get_endpoint(Endpoints.DETAIL),
268
- json=payload,
269
- headers={"Content-Type": "application/json"},
270
- ) as response:
271
- if response.status != 200:
272
- print(f"Non-200 status from detail endpoint: {response.status}")
273
- return None
274
-
275
- # Fix for FlashForge printer's malformed Content-Type header
276
- # Some printers return "appliation/json" instead of "application/json"
277
- # We'll manually parse the text as JSON to bypass Content-Type validation
278
- try:
279
- data = await response.json()
280
- except aiohttp.ContentTypeError:
281
- # Fallback: manually parse as JSON if Content-Type is malformed
282
- text = await response.text()
283
- import json
284
-
285
- data = json.loads(text)
286
-
287
- return DetailResponse(**data)
264
+ session = await self.client.get_http_session()
265
+ async with session.post(
266
+ self.client.get_endpoint(Endpoints.DETAIL),
267
+ json=payload,
268
+ headers={"Content-Type": "application/json"},
269
+ ) as response:
270
+ if response.status != 200:
271
+ print(f"Non-200 status from detail endpoint: {response.status}")
272
+ return None
273
+
274
+ data = await json_from_response(response)
275
+ return DetailResponse(**data)
288
276
 
289
277
  except Exception as error:
290
278
  print(f"GetDetailResponse Request error: {error}")
@@ -17,7 +17,7 @@ from ...models.responses import (
17
17
  AD5XUploadParams,
18
18
  )
19
19
  from ..constants.endpoints import Endpoints
20
- from ..network.utils import NetworkUtils
20
+ from ..network.utils import NetworkUtils, json_from_response
21
21
 
22
22
  if TYPE_CHECKING:
23
23
  from ...client import FlashForgeClient
@@ -180,17 +180,7 @@ class JobControl:
180
180
  print(f"Upload failed: Printer responded with status {response.status}")
181
181
  return False
182
182
 
183
- # Fix for FlashForge printer's malformed Content-Type header
184
- # Some printers return "appliation/json" instead of "application/json"
185
- try:
186
- result = await response.json()
187
- except aiohttp.ContentTypeError:
188
- # Fallback: manually parse as JSON if Content-Type is malformed
189
- text = await response.text()
190
- import json
191
-
192
- result = json.loads(text)
193
-
183
+ result = await json_from_response(response)
194
184
  print("Upload Response Data:", result)
195
185
 
196
186
  if NetworkUtils.is_ok(result):
@@ -240,27 +230,17 @@ class JobControl:
240
230
  }
241
231
 
242
232
  try:
243
- async with aiohttp.ClientSession() as session:
244
- async with session.post(
245
- self.client.get_endpoint(Endpoints.GCODE_PRINT),
246
- json=payload,
247
- headers={"Content-Type": "application/json"},
248
- ) as response:
249
- if response.status != 200:
250
- return False
251
-
252
- # Fix for FlashForge printer's malformed Content-Type header
253
- # Some printers return "appliation/json" instead of "application/json"
254
- try:
255
- result = await response.json()
256
- except aiohttp.ContentTypeError:
257
- # Fallback: manually parse as JSON if Content-Type is malformed
258
- text = await response.text()
259
- import json
260
-
261
- result = json.loads(text)
262
-
263
- return NetworkUtils.is_ok(result)
233
+ session = await self.client.get_http_session()
234
+ async with session.post(
235
+ self.client.get_endpoint(Endpoints.GCODE_PRINT),
236
+ json=payload,
237
+ headers={"Content-Type": "application/json"},
238
+ ) as response:
239
+ if response.status != 200:
240
+ return False
241
+
242
+ result = await json_from_response(response)
243
+ return NetworkUtils.is_ok(result)
264
244
 
265
245
  except Exception as error:
266
246
  print(f"PrintLocalFile error: {error}")
@@ -344,13 +324,7 @@ class JobControl:
344
324
  )
345
325
  return False
346
326
 
347
- # Fix for FlashForge printer's malformed Content-Type header
348
- try:
349
- result = await response.json()
350
- except aiohttp.ContentTypeError:
351
- text = await response.text()
352
- result = json.loads(text)
353
-
327
+ result = await json_from_response(response)
354
328
  print("AD5X Upload Response Data:", result)
355
329
 
356
330
  if NetworkUtils.is_ok(result):
@@ -415,23 +389,17 @@ class JobControl:
415
389
  }
416
390
 
417
391
  try:
418
- async with aiohttp.ClientSession() as session:
419
- async with session.post(
420
- self.client.get_endpoint(Endpoints.GCODE_PRINT),
421
- json=payload,
422
- headers={"Content-Type": "application/json"},
423
- ) as response:
424
- if response.status != 200:
425
- return False
426
-
427
- # Fix for FlashForge printer's malformed Content-Type header
428
- try:
429
- result = await response.json()
430
- except aiohttp.ContentTypeError:
431
- text = await response.text()
432
- result = json.loads(text)
433
-
434
- return NetworkUtils.is_ok(result)
392
+ session = await self.client.get_http_session()
393
+ async with session.post(
394
+ self.client.get_endpoint(Endpoints.GCODE_PRINT),
395
+ json=payload,
396
+ headers={"Content-Type": "application/json"},
397
+ ) as response:
398
+ if response.status != 200:
399
+ return False
400
+
401
+ result = await json_from_response(response)
402
+ return NetworkUtils.is_ok(result)
435
403
 
436
404
  except Exception as error:
437
405
  print(f"AD5X Multi-Color Job error: {error}")
@@ -473,23 +441,17 @@ class JobControl:
473
441
  }
474
442
 
475
443
  try:
476
- async with aiohttp.ClientSession() as session:
477
- async with session.post(
478
- self.client.get_endpoint(Endpoints.GCODE_PRINT),
479
- json=payload,
480
- headers={"Content-Type": "application/json"},
481
- ) as response:
482
- if response.status != 200:
483
- return False
484
-
485
- # Fix for FlashForge printer's malformed Content-Type header
486
- try:
487
- result = await response.json()
488
- except aiohttp.ContentTypeError:
489
- text = await response.text()
490
- result = json.loads(text)
491
-
492
- return NetworkUtils.is_ok(result)
444
+ session = await self.client.get_http_session()
445
+ async with session.post(
446
+ self.client.get_endpoint(Endpoints.GCODE_PRINT),
447
+ json=payload,
448
+ headers={"Content-Type": "application/json"},
449
+ ) as response:
450
+ if response.status != 200:
451
+ return False
452
+
453
+ result = await json_from_response(response)
454
+ return NetworkUtils.is_ok(result)
493
455
 
494
456
  except Exception as error:
495
457
  print(f"AD5X Single-Color Job error: {error}")
@@ -2,9 +2,26 @@
2
2
  FlashForge Python API - Network Utilities
3
3
  """
4
4
 
5
+ import json
6
+
7
+ import aiohttp
8
+
5
9
  from ...models.responses import GenericResponse
6
10
 
7
11
 
12
+ async def json_from_response(response: aiohttp.ClientResponse) -> dict:
13
+ """Parse JSON from a response, with a fallback for malformed Content-Type headers.
14
+
15
+ Some FlashForge printers return "appliation/json" instead of "application/json",
16
+ causing aiohttp to raise ContentTypeError. This helper handles both cases.
17
+ """
18
+ try:
19
+ return await response.json() # type: ignore[no-any-return]
20
+ except aiohttp.ContentTypeError:
21
+ text = await response.text()
22
+ return json.loads(text)
23
+
24
+
8
25
  class NetworkUtils:
9
26
  """Utility class for handling network responses and status codes."""
10
27
 
flashforge/client.py CHANGED
@@ -10,10 +10,10 @@ from dataclasses import dataclass
10
10
 
11
11
  import aiohttp
12
12
 
13
- from .api.constants.endpoints import Endpoints
13
+ from .api.constants.endpoints import CAMERA_STREAM_PORT, Endpoints
14
14
  from .api.controls import Control, Files, Info, JobControl, TempControl
15
15
  from .api.controls.info import MachineInfoParser
16
- from .api.network.utils import NetworkUtils
16
+ from .api.network.utils import NetworkUtils, json_from_response
17
17
  from .models import FFMachineInfo, Product, ProductResponse
18
18
  from .tcp import FlashForgeClient as TcpClient
19
19
  from .tcp import FlashForgeTcpClientOptions, PrinterInfo
@@ -60,7 +60,7 @@ class FlashForgeClient:
60
60
 
61
61
  # Constants
62
62
  self._PORT = options.http_port if options and options.http_port is not None else 8898
63
- self._HTTP_TIMEOUT = 5.0
63
+ self._HTTP_TIMEOUT = 15.0
64
64
 
65
65
  # HTTP client state
66
66
  self._http_session: aiohttp.ClientSession | None = None
@@ -221,6 +221,59 @@ class FlashForgeClient:
221
221
 
222
222
  self.camera_stream_url = ""
223
223
 
224
+ async def detect_camera_stream(self, timeout_ms: int = 3000) -> str:
225
+ """
226
+ Probes the printer's known OEM camera endpoint.
227
+
228
+ Falls back to a short GET when HEAD is unsupported so MJPEG servers
229
+ that reject HEAD requests are still detected.
230
+
231
+ Args:
232
+ timeout_ms: Timeout for each probe attempt in milliseconds.
233
+
234
+ Returns:
235
+ The working camera stream URL, or an empty string if not detected.
236
+ """
237
+ probe_url = f"http://{self.ip_address}:{CAMERA_STREAM_PORT}/?action=stream"
238
+ timeout = aiohttp.ClientTimeout(total=timeout_ms / 1000)
239
+
240
+ if await self._probe_camera_stream_with_method("head", probe_url, timeout):
241
+ return probe_url
242
+
243
+ if await self._probe_camera_stream_with_method("get", probe_url, timeout):
244
+ return probe_url
245
+
246
+ return ""
247
+
248
+ async def _probe_camera_stream_with_method(
249
+ self,
250
+ method: str,
251
+ probe_url: str,
252
+ timeout: aiohttp.ClientTimeout,
253
+ ) -> bool:
254
+ try:
255
+ session = await self._ensure_http_session()
256
+ request = session.head if method == "head" else session.get
257
+
258
+ async with request(probe_url, timeout=timeout) as response:
259
+ return self._is_valid_camera_probe_response(
260
+ response.status, response.headers.get("Content-Type", "")
261
+ )
262
+ except Exception:
263
+ return False
264
+
265
+ @staticmethod
266
+ def _is_valid_camera_probe_response(status: int, content_type: str | None) -> bool:
267
+ if status != 200:
268
+ return False
269
+
270
+ normalized_content_type = (content_type or "").lower()
271
+ return (
272
+ normalized_content_type == ""
273
+ or "multipart" in normalized_content_type
274
+ or "video/x-mjpeg" in normalized_content_type
275
+ )
276
+
224
277
  def cache_details(self, info: FFMachineInfo | None) -> bool:
225
278
  """
226
279
  Caches machine details from the provided FFMachineInfo object.
@@ -326,16 +379,7 @@ class FlashForgeClient:
326
379
  if response.status != 200:
327
380
  return False
328
381
 
329
- # Fix for FlashForge printer's malformed Content-Type header
330
- # Some printers return "appliation/json" instead of "application/json"
331
- try:
332
- data = await response.json()
333
- except aiohttp.ContentTypeError:
334
- # Fallback: manually parse as JSON if Content-Type is malformed
335
- text = await response.text()
336
- import json
337
-
338
- data = json.loads(text)
382
+ data = await json_from_response(response)
339
383
 
340
384
  # Validate response structure
341
385
  if not NetworkUtils.is_ok(data):