flashforge-python-api 1.3.2__py3-none-any.whl → 1.3.4__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 +5 -1
- flashforge/api/controls/control.py +16 -9
- flashforge/api/controls/creator5_palette.py +6 -3
- flashforge/api/controls/files.py +10 -4
- flashforge/api/controls/info.py +123 -21
- flashforge/api/controls/job_control.py +106 -68
- flashforge/api/controls/temp_control.py +15 -6
- flashforge/api/misc/redaction.py +88 -0
- flashforge/client.py +9 -9
- flashforge/exceptions.py +51 -0
- flashforge/models/machine_info.py +196 -99
- flashforge/tcp/parsers/endstop_status.py +7 -5
- flashforge/tcp/parsers/print_status.py +5 -2
- flashforge/tcp/parsers/thumbnail_info.py +18 -11
- {flashforge_python_api-1.3.2.dist-info → flashforge_python_api-1.3.4.dist-info}/METADATA +1 -1
- {flashforge_python_api-1.3.2.dist-info → flashforge_python_api-1.3.4.dist-info}/RECORD +19 -17
- {flashforge_python_api-1.3.2.dist-info → flashforge_python_api-1.3.4.dist-info}/WHEEL +0 -0
- {flashforge_python_api-1.3.2.dist-info → flashforge_python_api-1.3.4.dist-info}/entry_points.txt +0 -0
- {flashforge_python_api-1.3.2.dist-info → flashforge_python_api-1.3.4.dist-info}/licenses/LICENSE +0 -0
flashforge/__init__.py
CHANGED
|
@@ -69,6 +69,7 @@ from .discovery import (
|
|
|
69
69
|
PrinterStatus,
|
|
70
70
|
SocketCreationError,
|
|
71
71
|
)
|
|
72
|
+
from .exceptions import FlashForgeError, FlashForgeResponseError
|
|
72
73
|
|
|
73
74
|
# Import key models for convenience
|
|
74
75
|
from .models import (
|
|
@@ -127,7 +128,7 @@ from .tcp import (
|
|
|
127
128
|
)
|
|
128
129
|
|
|
129
130
|
FiveMClient = FlashForgeClient
|
|
130
|
-
__version__ = "1.3.
|
|
131
|
+
__version__ = "1.3.4"
|
|
131
132
|
__author__ = "FlashForge Python API Contributors"
|
|
132
133
|
__email__ = "notghosttypes@gmail.com"
|
|
133
134
|
__description__ = "Python library for controlling FlashForge 3D printers"
|
|
@@ -138,6 +139,9 @@ __all__ = [
|
|
|
138
139
|
"FlashForgeClient",
|
|
139
140
|
"FiveMClient",
|
|
140
141
|
"FiveMClientConnectionOptions",
|
|
142
|
+
# Exceptions
|
|
143
|
+
"FlashForgeError",
|
|
144
|
+
"FlashForgeResponseError",
|
|
141
145
|
# Data models
|
|
142
146
|
"FFMachineInfo",
|
|
143
147
|
"FFPrinterDetail",
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
FlashForge Python API - Control Module
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
import logging
|
|
5
6
|
from typing import TYPE_CHECKING, Any
|
|
6
7
|
|
|
7
8
|
from ...models.responses import FilamentArgs
|
|
@@ -14,6 +15,8 @@ if TYPE_CHECKING:
|
|
|
14
15
|
from ...client import FlashForgeClient
|
|
15
16
|
from ...tcp.ff_client import FlashForgeClient as TcpClient
|
|
16
17
|
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
17
20
|
|
|
18
21
|
class Control:
|
|
19
22
|
"""
|
|
@@ -75,7 +78,7 @@ class Control:
|
|
|
75
78
|
return await self._send_filtration_command(
|
|
76
79
|
FilamentArgs(internal="close", external="open")
|
|
77
80
|
)
|
|
78
|
-
|
|
81
|
+
logger.warning("set_external_filtration_on: filtration is not equipped on this printer.")
|
|
79
82
|
return False
|
|
80
83
|
|
|
81
84
|
async def set_internal_filtration_on(self) -> bool:
|
|
@@ -90,7 +93,7 @@ class Control:
|
|
|
90
93
|
return await self._send_filtration_command(
|
|
91
94
|
FilamentArgs(internal="open", external="close")
|
|
92
95
|
)
|
|
93
|
-
|
|
96
|
+
logger.warning("set_internal_filtration_on: filtration is not equipped on this printer.")
|
|
94
97
|
return False
|
|
95
98
|
|
|
96
99
|
async def set_filtration_off(self) -> bool:
|
|
@@ -105,7 +108,7 @@ class Control:
|
|
|
105
108
|
return await self._send_filtration_command(
|
|
106
109
|
FilamentArgs(internal="close", external="close")
|
|
107
110
|
)
|
|
108
|
-
|
|
111
|
+
logger.warning("set_filtration_off: filtration is not equipped on this printer.")
|
|
109
112
|
return False
|
|
110
113
|
|
|
111
114
|
async def turn_camera_on(self) -> bool:
|
|
@@ -188,7 +191,7 @@ class Control:
|
|
|
188
191
|
True if the command is successful, False otherwise.
|
|
189
192
|
"""
|
|
190
193
|
if not self.client.led_control:
|
|
191
|
-
|
|
194
|
+
logger.warning("set_led_on: LED control is not equipped on this printer.")
|
|
192
195
|
return False
|
|
193
196
|
return await self.send_control_command(Commands.LIGHT_CONTROL_CMD, {"status": "open"})
|
|
194
197
|
|
|
@@ -200,7 +203,7 @@ class Control:
|
|
|
200
203
|
True if the command is successful, False otherwise.
|
|
201
204
|
"""
|
|
202
205
|
if not self.client.led_control:
|
|
203
|
-
|
|
206
|
+
logger.warning("set_led_off: LED control is not equipped on this printer.")
|
|
204
207
|
return False
|
|
205
208
|
return await self.send_control_command(Commands.LIGHT_CONTROL_CMD, {"status": "close"})
|
|
206
209
|
|
|
@@ -259,7 +262,9 @@ class Control:
|
|
|
259
262
|
True if the command is successful, False otherwise.
|
|
260
263
|
"""
|
|
261
264
|
if not self.client.is_ad5x and not self.client.is_creator5:
|
|
262
|
-
|
|
265
|
+
logger.warning(
|
|
266
|
+
"configure_slot: the material station is only available on the AD5X / Creator 5."
|
|
267
|
+
)
|
|
263
268
|
return False
|
|
264
269
|
# The AD5X and Creator 5 use MUTUALLY EXCLUSIVE color wire formats (see
|
|
265
270
|
# creator5_palette for the firmware match rules), so model-gate here:
|
|
@@ -293,7 +298,9 @@ class Control:
|
|
|
293
298
|
"payload": {"cmd": command, "args": args},
|
|
294
299
|
}
|
|
295
300
|
|
|
296
|
-
|
|
301
|
+
# Log the command, never `payload`: it carries the serial number and
|
|
302
|
+
# check code, and these lines end up pasted into bug reports.
|
|
303
|
+
logger.debug("Sending control command %s with args %s", command, args)
|
|
297
304
|
|
|
298
305
|
try:
|
|
299
306
|
await self.client.is_http_client_busy()
|
|
@@ -305,12 +312,12 @@ class Control:
|
|
|
305
312
|
headers={"Content-Type": "application/json"},
|
|
306
313
|
) as response:
|
|
307
314
|
data = await json_from_response(response)
|
|
308
|
-
|
|
315
|
+
logger.debug("Control command %s reply: %s", command, data)
|
|
309
316
|
|
|
310
317
|
return NetworkUtils.is_ok(data)
|
|
311
318
|
|
|
312
319
|
except Exception as e:
|
|
313
|
-
|
|
320
|
+
logger.warning("Error in send_control_command (%s): %s", command, e)
|
|
314
321
|
return False
|
|
315
322
|
finally:
|
|
316
323
|
self.client.release_http_client()
|
|
@@ -16,6 +16,7 @@ for the model-gating that splits them.
|
|
|
16
16
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
|
+
import logging
|
|
19
20
|
import math
|
|
20
21
|
import re
|
|
21
22
|
from dataclasses import dataclass
|
|
@@ -25,6 +26,8 @@ from dataclasses import dataclass
|
|
|
25
26
|
# reference. PEP 8 lowercase locals are therefore relaxed here on purpose.
|
|
26
27
|
# ruff: noqa: N806
|
|
27
28
|
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
28
31
|
|
|
29
32
|
@dataclass(frozen=True)
|
|
30
33
|
class Creator5PaletteColor:
|
|
@@ -238,9 +241,9 @@ def snap_to_creator5_palette(hex_str: str) -> Creator5PaletteColor:
|
|
|
238
241
|
"""
|
|
239
242
|
rgb = _hex_to_rgb(hex_str)
|
|
240
243
|
if rgb is None:
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
+
logger.warning(
|
|
245
|
+
'snap_to_creator5_palette: could not parse "%s" as hex; falling back to White.',
|
|
246
|
+
hex_str,
|
|
244
247
|
)
|
|
245
248
|
return CREATOR5_PALETTE[0]
|
|
246
249
|
|
flashforge/api/controls/files.py
CHANGED
|
@@ -90,7 +90,10 @@ class Files:
|
|
|
90
90
|
data = await json_from_response(response)
|
|
91
91
|
|
|
92
92
|
if not NetworkUtils.is_ok(data):
|
|
93
|
-
|
|
93
|
+
logger.warning(
|
|
94
|
+
"Error retrieving the file list: %s",
|
|
95
|
+
NetworkUtils.get_error_message(data),
|
|
96
|
+
)
|
|
94
97
|
return []
|
|
95
98
|
|
|
96
99
|
# Parse the response using GCodeListResponse
|
|
@@ -147,7 +150,7 @@ class Files:
|
|
|
147
150
|
return []
|
|
148
151
|
|
|
149
152
|
except Exception as err:
|
|
150
|
-
|
|
153
|
+
logger.warning("get_recent_file_list error: %s", err)
|
|
151
154
|
return []
|
|
152
155
|
|
|
153
156
|
async def get_gcode_thumbnail(self, file_name: str) -> bytes | None:
|
|
@@ -185,9 +188,12 @@ class Files:
|
|
|
185
188
|
result = ThumbnailResponse(**data)
|
|
186
189
|
return base64.b64decode(result.image_data)
|
|
187
190
|
else:
|
|
188
|
-
|
|
191
|
+
logger.warning(
|
|
192
|
+
"Error retrieving the thumbnail: %s",
|
|
193
|
+
NetworkUtils.get_error_message(data),
|
|
194
|
+
)
|
|
189
195
|
return None
|
|
190
196
|
|
|
191
197
|
except Exception as err:
|
|
192
|
-
|
|
198
|
+
logger.warning("get_gcode_thumbnail error: %s", err)
|
|
193
199
|
return None
|
flashforge/api/controls/info.py
CHANGED
|
@@ -2,18 +2,23 @@
|
|
|
2
2
|
FlashForge Python API - Info Module
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
import logging
|
|
5
6
|
import re
|
|
6
7
|
from datetime import datetime, timedelta
|
|
7
|
-
from typing import TYPE_CHECKING
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
8
9
|
|
|
10
|
+
from ...exceptions import FlashForgeResponseError
|
|
9
11
|
from ...models.machine_info import FFMachineInfo, MachineState, Temperature
|
|
10
12
|
from ...models.responses import DetailResponse, FFPrinterDetail
|
|
11
13
|
from ..constants.endpoints import Endpoints
|
|
14
|
+
from ..misc.redaction import redact_model
|
|
12
15
|
from ..network.utils import json_from_response
|
|
13
16
|
|
|
14
17
|
if TYPE_CHECKING:
|
|
15
18
|
from ...client import FlashForgeClient
|
|
16
19
|
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
17
22
|
|
|
18
23
|
# Firmware-reported PIDs from FlashForge's /detail endpoint. These are stable
|
|
19
24
|
# identifiers set by firmware, unlike the user-mutable `name` field.
|
|
@@ -161,6 +166,12 @@ class MachineInfoParser:
|
|
|
161
166
|
)
|
|
162
167
|
has_lidar = getattr(detail, "lidar", None) == 1
|
|
163
168
|
has_door_sensor = is_creator5_pro
|
|
169
|
+
# Not every Creator 5 has the heated chamber. FFPrinterDetail maps
|
|
170
|
+
# the firmware's "no sensor" sentinel (-108) to None, so an absent
|
|
171
|
+
# reading is the signal - never the model family, which is what left
|
|
172
|
+
# chamber-less units with three entities pinned at 0 C.
|
|
173
|
+
chamber_temp_value = getattr(detail, "chamber_temp", None)
|
|
174
|
+
has_chamber_sensor = chamber_temp_value is not None
|
|
164
175
|
|
|
165
176
|
# Immutable model name resolution: prefer the firmware `model`
|
|
166
177
|
# field, then a PID-derived name, then the user-set name.
|
|
@@ -233,6 +244,7 @@ class MachineInfoParser:
|
|
|
233
244
|
has_camera=has_camera,
|
|
234
245
|
has_lidar=has_lidar,
|
|
235
246
|
has_door_sensor=has_door_sensor,
|
|
247
|
+
has_chamber_sensor=has_chamber_sensor,
|
|
236
248
|
# Current print stats
|
|
237
249
|
print_duration=getattr(detail, "print_duration", 0) or 0,
|
|
238
250
|
print_file_name=getattr(detail, "print_file_name", "") or "",
|
|
@@ -267,8 +279,14 @@ class MachineInfoParser:
|
|
|
267
279
|
return machine_info
|
|
268
280
|
|
|
269
281
|
except Exception as error:
|
|
270
|
-
|
|
271
|
-
|
|
282
|
+
logger.warning(
|
|
283
|
+
"Could not parse the /detail payload into FFMachineInfo; the caller sees "
|
|
284
|
+
"this as an unreachable printer. %s",
|
|
285
|
+
error,
|
|
286
|
+
)
|
|
287
|
+
# Redacted: this dump carries the MAC, IP and cloud registration
|
|
288
|
+
# codes, and it exists to be pasted into a bug report.
|
|
289
|
+
logger.debug("Detail object that failed to parse: %s", redact_model(detail))
|
|
272
290
|
return None
|
|
273
291
|
|
|
274
292
|
@staticmethod
|
|
@@ -303,7 +321,7 @@ class MachineInfoParser:
|
|
|
303
321
|
return state_mapping[valid_status]
|
|
304
322
|
|
|
305
323
|
if valid_status:
|
|
306
|
-
|
|
324
|
+
logger.warning("Unknown machine status received: %r", status)
|
|
307
325
|
return MachineState.UNKNOWN
|
|
308
326
|
|
|
309
327
|
|
|
@@ -328,21 +346,41 @@ class Info:
|
|
|
328
346
|
This method fetches detailed data from the printer and transforms it.
|
|
329
347
|
|
|
330
348
|
Returns:
|
|
331
|
-
An FFMachineInfo object, or None if
|
|
349
|
+
An FFMachineInfo object, or None if the printer could not be reached.
|
|
350
|
+
|
|
351
|
+
Raises:
|
|
352
|
+
FlashForgeResponseError: The printer answered but the payload could
|
|
353
|
+
not be understood. Distinct from a None return, which means the
|
|
354
|
+
request never got through.
|
|
332
355
|
"""
|
|
333
356
|
detail_response = await self.get_detail_response()
|
|
334
|
-
if detail_response
|
|
335
|
-
return
|
|
336
|
-
|
|
357
|
+
if detail_response is None or detail_response.detail is None:
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
machine_info = MachineInfoParser.from_detail(detail_response.detail)
|
|
361
|
+
if machine_info is None:
|
|
362
|
+
# We validated the payload but could not build our own model from
|
|
363
|
+
# it - that is a bug on our side, not an unreachable printer, and
|
|
364
|
+
# reporting it as the latter is what issue #18 was about.
|
|
365
|
+
raise FlashForgeResponseError(
|
|
366
|
+
"The /detail payload was read but could not be converted into machine info",
|
|
367
|
+
endpoint=Endpoints.DETAIL,
|
|
368
|
+
)
|
|
369
|
+
return machine_info
|
|
337
370
|
|
|
338
371
|
async def is_printing(self) -> bool:
|
|
339
372
|
"""
|
|
340
373
|
Checks if the printer is currently in the "printing" state.
|
|
341
374
|
|
|
375
|
+
These three convenience wrappers keep their documented "return the
|
|
376
|
+
fallback on any failure" contract, so a caller polling for a boolean is
|
|
377
|
+
not forced to handle an exception. The failure is still logged, and
|
|
378
|
+
callers that need to distinguish causes should use `get()` directly.
|
|
379
|
+
|
|
342
380
|
Returns:
|
|
343
381
|
True if the printer is printing, False otherwise or if status cannot be determined.
|
|
344
382
|
"""
|
|
345
|
-
info = await self.
|
|
383
|
+
info = await self._get_or_none()
|
|
346
384
|
return info.status == "printing" if info else False
|
|
347
385
|
|
|
348
386
|
async def get_status(self) -> str | None:
|
|
@@ -352,7 +390,7 @@ class Info:
|
|
|
352
390
|
Returns:
|
|
353
391
|
The status string, or None if it cannot be determined.
|
|
354
392
|
"""
|
|
355
|
-
info = await self.
|
|
393
|
+
info = await self._get_or_none()
|
|
356
394
|
return info.status if info else None
|
|
357
395
|
|
|
358
396
|
async def get_machine_state(self) -> MachineState | None:
|
|
@@ -362,17 +400,34 @@ class Info:
|
|
|
362
400
|
Returns:
|
|
363
401
|
A MachineState enum value, or None if it cannot be determined.
|
|
364
402
|
"""
|
|
365
|
-
info = await self.
|
|
403
|
+
info = await self._get_or_none()
|
|
366
404
|
return info.machine_state if info else None
|
|
367
405
|
|
|
368
|
-
async def
|
|
406
|
+
async def _get_or_none(self) -> FFMachineInfo | None:
|
|
407
|
+
"""Call `get()`, absorbing a response error into a None for the convenience wrappers."""
|
|
408
|
+
try:
|
|
409
|
+
return await self.get()
|
|
410
|
+
except FlashForgeResponseError as error:
|
|
411
|
+
logger.warning("Could not read machine info: %s", error)
|
|
412
|
+
return None
|
|
413
|
+
|
|
414
|
+
async def get_detail_raw(self) -> dict[str, Any] | None:
|
|
369
415
|
"""
|
|
370
|
-
Retrieves the
|
|
371
|
-
|
|
416
|
+
Retrieves the /detail response as the plain decoded JSON dict, with no
|
|
417
|
+
model validation applied.
|
|
418
|
+
|
|
419
|
+
This exists so callers can inspect identity fields - above all `pid` -
|
|
420
|
+
without first having to successfully validate the ~50 unrelated fields
|
|
421
|
+
in the payload. A supported printer must never be rejected because of a
|
|
422
|
+
field that has nothing to do with whether it is supported (issue #18).
|
|
372
423
|
|
|
373
424
|
Returns:
|
|
374
|
-
|
|
375
|
-
or
|
|
425
|
+
The decoded response body, or None if the printer could not be
|
|
426
|
+
reached or answered with a non-200 status.
|
|
427
|
+
|
|
428
|
+
Raises:
|
|
429
|
+
FlashForgeResponseError: The printer answered with a body that is
|
|
430
|
+
not decodable JSON.
|
|
376
431
|
"""
|
|
377
432
|
payload = {"serialNumber": self.client.serial_number, "checkCode": self.client.check_code}
|
|
378
433
|
|
|
@@ -384,12 +439,59 @@ class Info:
|
|
|
384
439
|
headers={"Content-Type": "application/json"},
|
|
385
440
|
) as response:
|
|
386
441
|
if response.status != 200:
|
|
387
|
-
|
|
442
|
+
logger.warning("Non-200 status from the /detail endpoint: %s", response.status)
|
|
388
443
|
return None
|
|
389
|
-
|
|
390
444
|
data = await json_from_response(response)
|
|
391
|
-
return DetailResponse(**data)
|
|
392
|
-
|
|
393
445
|
except Exception as error:
|
|
394
|
-
|
|
446
|
+
logger.warning(
|
|
447
|
+
"Could not reach the printer's /detail endpoint. This is a transport "
|
|
448
|
+
"failure (unreachable printer, timeout, or refused connection), not a "
|
|
449
|
+
"payload the library failed to read. %s",
|
|
450
|
+
error,
|
|
451
|
+
)
|
|
395
452
|
return None
|
|
453
|
+
|
|
454
|
+
if not isinstance(data, dict):
|
|
455
|
+
raise FlashForgeResponseError(
|
|
456
|
+
f"The /detail endpoint returned {type(data).__name__}, expected a JSON object",
|
|
457
|
+
endpoint=Endpoints.DETAIL,
|
|
458
|
+
)
|
|
459
|
+
return data
|
|
460
|
+
|
|
461
|
+
async def get_detail_response(self) -> DetailResponse | None:
|
|
462
|
+
"""
|
|
463
|
+
Retrieves the validated detailed response from the printer's detail endpoint.
|
|
464
|
+
This contains a wealth of information about the printer's current state.
|
|
465
|
+
|
|
466
|
+
Returns:
|
|
467
|
+
A DetailResponse object containing the raw printer details, or None
|
|
468
|
+
if the printer could not be reached.
|
|
469
|
+
|
|
470
|
+
Raises:
|
|
471
|
+
FlashForgeResponseError: The printer answered, but the body did not
|
|
472
|
+
validate. Callers should surface this differently from a None
|
|
473
|
+
return: None means "we never got an answer", this means "the
|
|
474
|
+
answer was not one we could read", and only the second is
|
|
475
|
+
actionable as a bug report.
|
|
476
|
+
"""
|
|
477
|
+
data = await self.get_detail_raw()
|
|
478
|
+
if data is None:
|
|
479
|
+
return None
|
|
480
|
+
|
|
481
|
+
try:
|
|
482
|
+
return DetailResponse(**data)
|
|
483
|
+
except Exception as error:
|
|
484
|
+
logger.warning(
|
|
485
|
+
"The printer answered /detail with a payload that failed validation. This "
|
|
486
|
+
"is NOT a connectivity problem - please report it with the debug dump "
|
|
487
|
+
"below. %s",
|
|
488
|
+
error,
|
|
489
|
+
)
|
|
490
|
+
# Redacted: this dump carries the MAC, IP and cloud registration
|
|
491
|
+
# codes, and it exists to be pasted into a bug report.
|
|
492
|
+
logger.debug("Payload that failed validation: %s", redact_model(data))
|
|
493
|
+
raise FlashForgeResponseError(
|
|
494
|
+
"The printer's /detail response could not be validated",
|
|
495
|
+
endpoint=Endpoints.DETAIL,
|
|
496
|
+
cause=error,
|
|
497
|
+
) from error
|