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 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.1"
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
- print("SetExternalFiltrationOn() error, filtration not equipped.")
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
- print("SetInternalFiltrationOn() error, filtration not equipped.")
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
- print("SetFiltrationOff() error, filtration not equipped.")
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
- print("SetLedOn() error, LED control not equipped.")
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
- print("SetLedOff() error, LED control not equipped.")
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
- print("configure_slot() error, material station only available on AD5X / Creator 5.")
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
- print(f"SendControlCommand:\n{payload}")
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
- print(f"Command reply: {data}")
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
- print(f"Error in send_control_command: {e}")
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
- print(
242
- f'snap_to_creator5_palette: could not parse "{hex_str}" as hex; '
243
- "falling back to White."
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
 
@@ -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
- print(f"Error retrieving file list: {NetworkUtils.get_error_message(data)}")
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
- print(f"GetRecentFileList error: {err}")
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
- print(f"Error retrieving thumbnail: {NetworkUtils.get_error_message(data)}")
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
- print(f"GetGcodeThumbnail error: {err}")
198
+ logger.warning("get_gcode_thumbnail error: %s", err)
193
199
  return None
@@ -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
- print(f"Error in MachineInfoParser.from_detail: {error}")
271
- print(f"Detail object causing error: {detail}")
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
- print(f"Unknown machine status received: '{status}'")
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 an error occurs or no data is returned.
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 and detail_response.detail:
335
- return MachineInfoParser.from_detail(detail_response.detail)
336
- return None
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.get()
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.get()
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.get()
403
+ info = await self._get_or_none()
366
404
  return info.machine_state if info else None
367
405
 
368
- async def get_detail_response(self) -> DetailResponse | None:
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 raw detailed response from the printer's detail endpoint.
371
- This contains a wealth of information about the printer's current state.
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
- A DetailResponse object containing the raw printer details,
375
- or None if the request fails or an error occurs.
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
- print(f"Non-200 status from detail endpoint: {response.status}")
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
- print(f"GetDetailResponse Request error: {error}")
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