flashforge-python-api 1.3.2__py3-none-any.whl → 1.3.3__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.
@@ -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,6 +2,7 @@
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
8
  from typing import TYPE_CHECKING
@@ -9,11 +10,14 @@ from typing import TYPE_CHECKING
9
10
  from ...models.machine_info import FFMachineInfo, MachineState, Temperature
10
11
  from ...models.responses import DetailResponse, FFPrinterDetail
11
12
  from ..constants.endpoints import Endpoints
13
+ from ..misc.redaction import redact_model
12
14
  from ..network.utils import json_from_response
13
15
 
14
16
  if TYPE_CHECKING:
15
17
  from ...client import FlashForgeClient
16
18
 
19
+ logger = logging.getLogger(__name__)
20
+
17
21
 
18
22
  # Firmware-reported PIDs from FlashForge's /detail endpoint. These are stable
19
23
  # identifiers set by firmware, unlike the user-mutable `name` field.
@@ -267,8 +271,14 @@ class MachineInfoParser:
267
271
  return machine_info
268
272
 
269
273
  except Exception as error:
270
- print(f"Error in MachineInfoParser.from_detail: {error}")
271
- print(f"Detail object causing error: {detail}")
274
+ logger.warning(
275
+ "Could not parse the /detail payload into FFMachineInfo; the caller sees "
276
+ "this as an unreachable printer. %s",
277
+ error,
278
+ )
279
+ # Redacted: this dump carries the MAC, IP and cloud registration
280
+ # codes, and it exists to be pasted into a bug report.
281
+ logger.debug("Detail object that failed to parse: %s", redact_model(detail))
272
282
  return None
273
283
 
274
284
  @staticmethod
@@ -303,7 +313,7 @@ class MachineInfoParser:
303
313
  return state_mapping[valid_status]
304
314
 
305
315
  if valid_status:
306
- print(f"Unknown machine status received: '{status}'")
316
+ logger.warning("Unknown machine status received: %r", status)
307
317
  return MachineState.UNKNOWN
308
318
 
309
319
 
@@ -384,12 +394,17 @@ class Info:
384
394
  headers={"Content-Type": "application/json"},
385
395
  ) as response:
386
396
  if response.status != 200:
387
- print(f"Non-200 status from detail endpoint: {response.status}")
397
+ logger.warning("Non-200 status from the /detail endpoint: %s", response.status)
388
398
  return None
389
399
 
390
400
  data = await json_from_response(response)
391
401
  return DetailResponse(**data)
392
402
 
393
403
  except Exception as error:
394
- print(f"GetDetailResponse Request error: {error}")
404
+ logger.warning(
405
+ "Could not read /detail; callers cannot tell this from an unreachable "
406
+ "printer or a rejected check code, so this line is the only record of "
407
+ "the real cause. %s",
408
+ error,
409
+ )
395
410
  return None
@@ -4,6 +4,7 @@ FlashForge Python API - Job Control Module
4
4
 
5
5
  import base64
6
6
  import json
7
+ import logging
7
8
  import re
8
9
  from pathlib import Path
9
10
  from typing import TYPE_CHECKING, Any
@@ -19,12 +20,15 @@ from ...models.responses import (
19
20
  Creator5UploadParams,
20
21
  )
21
22
  from ..constants.endpoints import Endpoints
23
+ from ..misc.redaction import redact_mapping
22
24
  from ..network.utils import NetworkUtils, json_from_response
23
25
 
24
26
  if TYPE_CHECKING:
25
27
  from ...client import FlashForgeClient
26
28
  from .control import Control
27
29
 
30
+ logger = logging.getLogger(__name__)
31
+
28
32
 
29
33
  class JobControl:
30
34
  """
@@ -136,14 +140,18 @@ class JobControl:
136
140
  file_path_obj = Path(file_path)
137
141
 
138
142
  if not file_path_obj.exists():
139
- print(f"UploadFile error: File not found at {file_path}")
143
+ logger.warning("upload_file: file not found at %s", file_path)
140
144
  return False
141
145
 
142
146
  file_size = file_path_obj.stat().st_size
143
147
  file_name = file_path_obj.name
144
148
 
145
- print(
146
- f"Starting upload for {file_name}, Size: {file_size}, Start: {start_print}, Level: {level_before_print}"
149
+ logger.debug(
150
+ "Starting upload for %s (size=%s, start=%s, level=%s)",
151
+ file_name,
152
+ file_size,
153
+ start_print,
154
+ level_before_print,
147
155
  )
148
156
 
149
157
  try:
@@ -159,16 +167,16 @@ class JobControl:
159
167
 
160
168
  # Add additional headers for new firmware
161
169
  if self._is_new_firmware_version():
162
- print("Using new firmware headers for upload.")
170
+ logger.debug("Using new firmware headers for upload.")
163
171
  custom_headers["flowCalibration"] = "false"
164
172
  custom_headers["useMatlStation"] = "false"
165
173
  custom_headers["gcodeToolCnt"] = "0"
166
174
  # Base64 encode "[]" which is "W10="
167
175
  custom_headers["materialMappings"] = "W10="
168
176
  else:
169
- print("Using old firmware headers for upload.")
177
+ logger.debug("Using old firmware headers for upload.")
170
178
 
171
- print("Upload Request Headers:", custom_headers)
179
+ logger.debug("Upload request headers: %s", redact_mapping(custom_headers))
172
180
 
173
181
  # Create multipart form data
174
182
  async with aiohttp.ClientSession() as session:
@@ -183,26 +191,31 @@ class JobControl:
183
191
  data=data,
184
192
  headers=custom_headers,
185
193
  ) as response:
186
- print(f"Upload Response Status: {response.status}")
194
+ logger.debug("Upload response status: %s", response.status)
187
195
 
188
196
  if response.status != 200:
189
- print(f"Upload failed: Printer responded with status {response.status}")
197
+ logger.warning(
198
+ "Upload failed: the printer responded with status %s",
199
+ response.status,
200
+ )
190
201
  return False
191
202
 
192
203
  result = await json_from_response(response)
193
- print("Upload Response Data:", result)
204
+ logger.debug("Upload response data: %s", result)
194
205
 
195
206
  if NetworkUtils.is_ok(result):
196
- print("Upload successful according to printer response.")
207
+ logger.debug("Upload successful according to the printer response.")
197
208
  return True
198
209
  else:
199
- print(
200
- f"Upload failed: Printer response code={result.get('code')}, message={result.get('message')}"
210
+ logger.warning(
211
+ "Upload failed: printer response code=%s, message=%s",
212
+ result.get("code"),
213
+ result.get("message"),
201
214
  )
202
215
  return False
203
216
 
204
217
  except Exception as e:
205
- print(f"UploadFile error: {e}")
218
+ logger.warning("upload_file error: %s", e)
206
219
  return False
207
220
 
208
221
  async def print_local_file(self, file_name: str, leveling_before_print: bool) -> bool:
@@ -252,7 +265,7 @@ class JobControl:
252
265
  return NetworkUtils.is_ok(result)
253
266
 
254
267
  except Exception as error:
255
- print(f"PrintLocalFile error: {error}")
268
+ logger.warning("print_local_file error: %s", error)
256
269
  raise error
257
270
 
258
271
  async def upload_file_ad5x(self, params: AD5XUploadParams) -> bool:
@@ -278,14 +291,19 @@ class JobControl:
278
291
  # Validate file exists
279
292
  file_path_obj = Path(params.file_path)
280
293
  if not file_path_obj.exists():
281
- print(f"UploadFileAD5X error: File not found at {params.file_path}")
294
+ logger.warning("upload_file_ad5x: file not found at %s", params.file_path)
282
295
  return False
283
296
 
284
297
  file_size = file_path_obj.stat().st_size
285
298
  file_name = file_path_obj.name
286
299
 
287
- print(
288
- f"Starting AD5X upload for {file_name}, Size: {file_size}, Start: {params.start_print}, Level: {params.leveling_before_print}, Tools: {len(params.material_mappings)}"
300
+ logger.debug(
301
+ "Starting AD5X upload for %s (size=%s, start=%s, level=%s, tools=%s)",
302
+ file_name,
303
+ file_size,
304
+ params.start_print,
305
+ params.leveling_before_print,
306
+ len(params.material_mappings),
289
307
  )
290
308
 
291
309
  try:
@@ -310,7 +328,7 @@ class JobControl:
310
328
  "Expect": "100-continue",
311
329
  }
312
330
 
313
- print("AD5X Upload Request Headers:", custom_headers)
331
+ logger.debug("AD5X upload request headers: %s", redact_mapping(custom_headers))
314
332
 
315
333
  # Create multipart form data
316
334
  async with aiohttp.ClientSession() as session:
@@ -325,28 +343,33 @@ class JobControl:
325
343
  data=data,
326
344
  headers=custom_headers,
327
345
  ) as response:
328
- print(f"AD5X Upload Response Status: {response.status}")
346
+ logger.debug("AD5X upload response status: %s", response.status)
329
347
 
330
348
  if response.status != 200:
331
- print(
332
- f"AD5X Upload failed: Printer responded with status {response.status}"
349
+ logger.warning(
350
+ "AD5X upload failed: the printer responded with status %s",
351
+ response.status,
333
352
  )
334
353
  return False
335
354
 
336
355
  result = await json_from_response(response)
337
- print("AD5X Upload Response Data:", result)
356
+ logger.debug("AD5X upload response data: %s", result)
338
357
 
339
358
  if NetworkUtils.is_ok(result):
340
- print("AD5X Upload successful according to printer response.")
359
+ logger.debug(
360
+ "AD5X upload successful according to the printer response."
361
+ )
341
362
  return True
342
363
  else:
343
- print(
344
- f"AD5X Upload failed: Printer response code={result.get('code')}, message={result.get('message')}"
364
+ logger.warning(
365
+ "AD5X upload failed: printer response code=%s, message=%s",
366
+ result.get("code"),
367
+ result.get("message"),
345
368
  )
346
369
  return False
347
370
 
348
371
  except Exception as e:
349
- print(f"UploadFileAD5X error: {e}")
372
+ logger.warning("upload_file_ad5x error: %s", e)
350
373
  return False
351
374
 
352
375
  async def start_ad5x_multi_color_job(self, params: AD5XLocalJobParams) -> bool:
@@ -371,7 +394,7 @@ class JobControl:
371
394
 
372
395
  # Validate file name
373
396
  if not params.file_name or params.file_name.strip() == "":
374
- print("AD5X Multi-Color Job error: fileName cannot be empty")
397
+ logger.warning("AD5X multi-color job: fileName cannot be empty.")
375
398
  return False
376
399
 
377
400
  # Create payload with AD5X-specific parameters
@@ -411,7 +434,7 @@ class JobControl:
411
434
  return NetworkUtils.is_ok(result)
412
435
 
413
436
  except Exception as error:
414
- print(f"AD5X Multi-Color Job error: {error}")
437
+ logger.warning("AD5X multi-color job error: %s", error)
415
438
  raise error
416
439
 
417
440
  async def start_ad5x_single_color_job(self, params: AD5XSingleColorJobParams) -> bool:
@@ -432,7 +455,7 @@ class JobControl:
432
455
 
433
456
  # Validate file name
434
457
  if not params.file_name or params.file_name.strip() == "":
435
- print("AD5X Single-Color Job error: fileName cannot be empty")
458
+ logger.warning("AD5X single-color job: fileName cannot be empty.")
436
459
  return False
437
460
 
438
461
  # Create payload with AD5X-specific parameters for single-color printing
@@ -463,7 +486,7 @@ class JobControl:
463
486
  return NetworkUtils.is_ok(result)
464
487
 
465
488
  except Exception as error:
466
- print(f"AD5X Single-Color Job error: {error}")
489
+ logger.warning("AD5X single-color job error: %s", error)
467
490
  raise error
468
491
 
469
492
  # --- Creator 5 / Creator 5 Pro ---
@@ -495,16 +518,21 @@ class JobControl:
495
518
  file_path_obj = Path(params.file_path)
496
519
 
497
520
  if not file_path_obj.exists():
498
- print(f"upload_file_creator5 error: File not found at {params.file_path}")
521
+ logger.warning("upload_file_creator5: file not found at %s", params.file_path)
499
522
  return False
500
523
 
501
524
  file_size = file_path_obj.stat().st_size
502
525
  file_name = file_path_obj.name
503
526
 
504
- print(
505
- f"Starting Creator 5 upload for {file_name}, Size: {file_size}, "
506
- f"Start: {params.start_print}, Level: {params.leveling_before_print}, "
507
- f"MatlStation: {params.use_matl_station}, Tools: {params.gcode_tool_cnt}"
527
+ logger.debug(
528
+ "Starting Creator 5 upload for %s (size=%s, start=%s, level=%s, "
529
+ "matl_station=%s, tools=%s)",
530
+ file_name,
531
+ file_size,
532
+ params.start_print,
533
+ params.leveling_before_print,
534
+ params.use_matl_station,
535
+ params.gcode_tool_cnt,
508
536
  )
509
537
 
510
538
  try:
@@ -524,7 +552,7 @@ class JobControl:
524
552
  "Expect": "100-continue",
525
553
  }
526
554
 
527
- print("Creator 5 Upload Request Headers:", custom_headers)
555
+ logger.debug("Creator 5 upload request headers: %s", redact_mapping(custom_headers))
528
556
 
529
557
  # Create multipart form data
530
558
  async with aiohttp.ClientSession() as session:
@@ -539,30 +567,33 @@ class JobControl:
539
567
  data=data,
540
568
  headers=custom_headers,
541
569
  ) as response:
542
- print(f"Creator 5 Upload Response Status: {response.status}")
570
+ logger.debug("Creator 5 upload response status: %s", response.status)
543
571
 
544
572
  if response.status != 200:
545
- print(
546
- f"Creator 5 Upload failed: Printer responded with "
547
- f"status {response.status}"
573
+ logger.warning(
574
+ "Creator 5 upload failed: the printer responded with status %s",
575
+ response.status,
548
576
  )
549
577
  return False
550
578
 
551
579
  result = await json_from_response(response)
552
- print("Creator 5 Upload Response Data:", result)
580
+ logger.debug("Creator 5 upload response data: %s", result)
553
581
 
554
582
  if NetworkUtils.is_ok(result):
555
- print("Creator 5 Upload successful according to printer response.")
583
+ logger.debug(
584
+ "Creator 5 upload successful according to the printer response."
585
+ )
556
586
  return True
557
587
 
558
- print(
559
- "Creator 5 Upload failed: Printer response code="
560
- f"{result.get('code')}, message={result.get('message')}"
588
+ logger.warning(
589
+ "Creator 5 upload failed: printer response code=%s, message=%s",
590
+ result.get("code"),
591
+ result.get("message"),
561
592
  )
562
593
  return False
563
594
 
564
595
  except Exception as e:
565
- print(f"upload_file_creator5 error: {e}")
596
+ logger.warning("upload_file_creator5 error: %s", e)
566
597
  return False
567
598
 
568
599
  async def start_creator5_job(self, params: Creator5JobParams) -> bool:
@@ -591,7 +622,7 @@ class JobControl:
591
622
  return False
592
623
 
593
624
  if not params.file_name or params.file_name.strip() == "":
594
- print("Creator 5 Job error: fileName cannot be empty")
625
+ logger.warning("Creator 5 job: fileName cannot be empty.")
595
626
  return False
596
627
 
597
628
  has_mappings = params.material_mappings is not None and len(params.material_mappings) > 0
@@ -634,7 +665,7 @@ class JobControl:
634
665
  return NetworkUtils.is_ok(result)
635
666
 
636
667
  except Exception as error:
637
- print(f"Creator 5 Job error: {error}")
668
+ logger.warning("Creator 5 job error: %s", error)
638
669
  raise error
639
670
 
640
671
  def _validate_material_station_printer(self) -> bool:
@@ -648,9 +679,8 @@ class JobControl:
648
679
  True if the printer supports material mappings, False otherwise.
649
680
  """
650
681
  if not self.client.is_ad5x and not self.client.is_creator5:
651
- print(
652
- "Material-station job error: this method requires an AD5X or "
653
- "Creator 5 series printer"
682
+ logger.warning(
683
+ "Material-station job: this method requires an AD5X or Creator 5 series printer."
654
684
  )
655
685
  return False
656
686
  return True
@@ -698,45 +728,53 @@ class JobControl:
698
728
  if not material_mappings or len(material_mappings) == 0:
699
729
  if allow_empty:
700
730
  return True
701
- print(f"{label} error: materialMappings array cannot be empty for multi-color jobs")
731
+ logger.warning("%s: materialMappings cannot be empty for multi-color jobs.", label)
702
732
  return False
703
733
 
704
734
  if len(material_mappings) > 4:
705
- print(f"{label} error: Maximum 4 material mappings allowed")
735
+ logger.warning("%s: a maximum of 4 material mappings is allowed.", label)
706
736
  return False
707
737
 
708
738
  hex_color_regex = re.compile(r"^#[0-9A-Fa-f]{6}$")
709
739
 
710
740
  for i, mapping in enumerate(material_mappings):
711
741
  if mapping.tool_id < 0 or mapping.tool_id > 3:
712
- print(
713
- f"{label} error: toolId must be between 0-3, "
714
- f"got {mapping.tool_id} at index {i}"
742
+ logger.warning(
743
+ "%s: toolId must be between 0-3, got %s at index %s",
744
+ label,
745
+ mapping.tool_id,
746
+ i,
715
747
  )
716
748
  return False
717
749
 
718
750
  if mapping.slot_id < 1 or mapping.slot_id > 4:
719
- print(
720
- f"{label} error: slotId must be between 1-4, "
721
- f"got {mapping.slot_id} at index {i}"
751
+ logger.warning(
752
+ "%s: slotId must be between 1-4, got %s at index %s",
753
+ label,
754
+ mapping.slot_id,
755
+ i,
722
756
  )
723
757
  return False
724
758
 
725
759
  if not mapping.material_name or mapping.material_name.strip() == "":
726
- print(f"{label} error: materialName cannot be empty at index {i}")
760
+ logger.warning("%s: materialName cannot be empty at index %s", label, i)
727
761
  return False
728
762
 
729
763
  if not hex_color_regex.match(mapping.tool_material_color):
730
- print(
731
- f"{label} error: toolMaterialColor must be in "
732
- f"#RRGGBB format, got {mapping.tool_material_color} at index {i}"
764
+ logger.warning(
765
+ "%s: toolMaterialColor must be in #RRGGBB format, got %s at index %s",
766
+ label,
767
+ mapping.tool_material_color,
768
+ i,
733
769
  )
734
770
  return False
735
771
 
736
772
  if not hex_color_regex.match(mapping.slot_material_color):
737
- print(
738
- f"{label} error: slotMaterialColor must be in "
739
- f"#RRGGBB format, got {mapping.slot_material_color} at index {i}"
773
+ logger.warning(
774
+ "%s: slotMaterialColor must be in #RRGGBB format, got %s at index %s",
775
+ label,
776
+ mapping.slot_material_color,
777
+ i,
740
778
  )
741
779
  return False
742
780
 
@@ -750,7 +788,7 @@ class JobControl:
750
788
  True if the printer is AD5X, false otherwise
751
789
  """
752
790
  if not self.client.is_ad5x:
753
- print("AD5X Job error: This method can only be used with AD5X printers")
791
+ logger.warning("AD5X job: this method can only be used with AD5X printers.")
754
792
  return False
755
793
  return True
756
794
 
@@ -781,7 +819,7 @@ class JobControl:
781
819
  json_string = json.dumps(json_array)
782
820
  return base64.b64encode(json_string.encode("utf-8")).decode("utf-8")
783
821
  except Exception as error:
784
- print("Failed to encode material mappings to base64:", error)
822
+ logger.warning("Failed to encode the material mappings to base64: %s", error)
785
823
  raise Exception(
786
824
  "Failed to encode material mappings for upload"
787
825
  ) from error # noqa: B904
@@ -6,6 +6,7 @@ direct TCP G-code/M-code commands; HTTP-only printers (Creator 5 / 5 Pro, no TCP
6
6
  channel) use the HTTP ``temperatureCtl_cmd`` instead.
7
7
  """
8
8
 
9
+ import logging
9
10
  from typing import TYPE_CHECKING
10
11
 
11
12
  from ..constants.commands import Commands
@@ -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
  # Sentinel value for `temperatureCtl_cmd` meaning "leave this heater unchanged"
19
22
  # (partial update). Sending a real 0 / -100 would turn the heater off.
@@ -106,7 +109,9 @@ class TempControl:
106
109
  The array, or None if ``tool_index`` is out of range.
107
110
  """
108
111
  if not isinstance(tool_index, int) or tool_index < 0 or tool_index >= NOZZLE_COUNT:
109
- print(f"TempControl: toolIndex {tool_index} out of range (0-{NOZZLE_COUNT - 1}).")
112
+ logger.warning(
113
+ "TempControl: tool_index %s out of range (0-%s).", tool_index, NOZZLE_COUNT - 1
114
+ )
110
115
  return None
111
116
  nozzles = [TEMP_NO_CHANGE] * NOZZLE_COUNT
112
117
  nozzles[tool_index] = value
@@ -145,7 +150,7 @@ class TempControl:
145
150
  True if the command is acknowledged, False otherwise.
146
151
  """
147
152
  if len(temps) != NOZZLE_COUNT:
148
- print(f"set_tool_temps: expected {NOZZLE_COUNT} temps, got {len(temps)}.")
153
+ logger.warning("set_tool_temps: expected %s temps, got %s.", NOZZLE_COUNT, len(temps))
149
154
  return False
150
155
  return await self._send_http_temp_command(nozzles=list(temps))
151
156
 
@@ -258,7 +263,9 @@ class TempControl:
258
263
  True if the command is acknowledged, False otherwise.
259
264
  """
260
265
  if not self.client.is_creator5 and not self.client.is_creator5_pro:
261
- print("set_chamber_temp() error, chamber heater only available on Creator 5 series.")
266
+ logger.warning(
267
+ "set_chamber_temp: the chamber heater is only available on the Creator 5 series."
268
+ )
262
269
  return False
263
270
  return await self._send_http_temp_command(chamber=temp)
264
271
 
@@ -270,7 +277,9 @@ class TempControl:
270
277
  True if the command is acknowledged, False otherwise.
271
278
  """
272
279
  if not self.client.is_creator5 and not self.client.is_creator5_pro:
273
- print("cancel_chamber_temp() error, chamber heater only available on Creator 5 series.")
280
+ logger.warning(
281
+ "cancel_chamber_temp: the chamber heater is only available on the Creator 5 series."
282
+ )
274
283
  return False
275
284
  return await self._send_http_temp_command(chamber=TEMP_OFF)
276
285
 
@@ -292,8 +301,8 @@ class TempControl:
292
301
  or HTTP-only (no TCP polling channel).
293
302
  """
294
303
  if self.client.http_only:
295
- print(
296
- "wait_for_part_cool() unavailable over HTTP-only connection; "
304
+ logger.warning(
305
+ "wait_for_part_cool is unavailable over an HTTP-only connection; "
297
306
  "poll info.get() instead."
298
307
  )
299
308
  return False
@@ -0,0 +1,88 @@
1
+ """
2
+ FlashForge Python API - Log redaction helpers.
3
+
4
+ Debug logs get pasted into bug reports verbatim, so anything that identifies or
5
+ grants control of a printer is masked before it can be formatted into a log
6
+ record. The key set mirrors what ``ff-5mp-hass`` already strips from its
7
+ diagnostics download, so the two surfaces cannot disagree about what is safe to
8
+ share.
9
+
10
+ - ``serialNumber`` / ``checkCode``: together these are full control of the
11
+ printer over the LAN API.
12
+ - ``flashRegisterCode`` / ``polarRegisterCode``: cloud-account registration
13
+ codes.
14
+ - ``macAddr`` / ``ipAddr``: identify the machine and its network.
15
+ """
16
+
17
+ from typing import Any
18
+
19
+ REDACTED = "<redacted>"
20
+
21
+ # Both the firmware's camelCase aliases and the library's snake_case field
22
+ # names, since payloads are logged in either form depending on the call site.
23
+ SENSITIVE_KEYS = frozenset(
24
+ {
25
+ "serialNumber",
26
+ "serial_number",
27
+ "checkCode",
28
+ "check_code",
29
+ "macAddr",
30
+ "mac_addr",
31
+ "mac_address",
32
+ "ipAddr",
33
+ "ip_addr",
34
+ "ip_address",
35
+ "flashRegisterCode",
36
+ "flash_register_code",
37
+ "flash_cloud_register_code",
38
+ "polarRegisterCode",
39
+ "polar_register_code",
40
+ "polar_cloud_register_code",
41
+ }
42
+ )
43
+
44
+
45
+ def _redact_value(value: Any) -> Any:
46
+ """Recurse into nested containers so a sensitive key cannot hide one level down."""
47
+ if isinstance(value, dict):
48
+ return redact_mapping(value)
49
+ if isinstance(value, list):
50
+ return [_redact_value(item) for item in value]
51
+ return value
52
+
53
+
54
+ def redact_mapping(mapping: dict[str, Any]) -> dict[str, Any]:
55
+ """Return a copy of ``mapping`` with every sensitive value masked.
56
+
57
+ The input is never mutated - callers are usually about to send it.
58
+ """
59
+ return {
60
+ key: (REDACTED if key in SENSITIVE_KEYS else _redact_value(value))
61
+ for key, value in mapping.items()
62
+ }
63
+
64
+
65
+ def redact_model(model: Any) -> Any:
66
+ """Return a log-safe representation of a Pydantic model (or anything else).
67
+
68
+ Falls back to ``repr`` for objects that cannot be dumped, and to a plain
69
+ ``<unloggable>`` if even that raises - a redaction helper must never be the
70
+ reason an error path fails.
71
+ """
72
+ if model is None:
73
+ return None
74
+
75
+ dump = getattr(model, "model_dump", None)
76
+ if callable(dump):
77
+ try:
78
+ return redact_mapping(dump())
79
+ except Exception: # noqa: BLE001 - logging must not raise
80
+ pass
81
+
82
+ if isinstance(model, dict):
83
+ return redact_mapping(model)
84
+
85
+ try:
86
+ return repr(model)
87
+ except Exception: # noqa: BLE001
88
+ return "<unloggable>"
flashforge/client.py CHANGED
@@ -164,7 +164,7 @@ class FlashForgeClient:
164
164
  True if TCP operations are permitted, False otherwise
165
165
  """
166
166
  if self._http_only:
167
- print(f"{op}() unavailable: printer has no TCP control channel (HTTP-only).")
167
+ logger.debug("%s() unavailable: printer has no TCP control channel (HTTP-only).", op)
168
168
  return False
169
169
  return True
170
170
 
@@ -212,7 +212,7 @@ class FlashForgeClient:
212
212
  connected = await self.verify_connection()
213
213
  if connected:
214
214
  return True
215
- print("Failed to connect to printer")
215
+ logger.warning("Failed to connect to the printer.")
216
216
  return False
217
217
 
218
218
  @property
@@ -260,7 +260,7 @@ class FlashForgeClient:
260
260
  """
261
261
  if await self.send_product_command():
262
262
  return await self.tcp_client.init_control()
263
- print("New API control failed!")
263
+ logger.warning("New API control failed; the product command was rejected.")
264
264
  return False
265
265
 
266
266
  async def dispose(self) -> None:
@@ -396,13 +396,13 @@ class FlashForgeClient:
396
396
  # Get HTTP API response
397
397
  response = await self.info.get_detail_response()
398
398
  if not response or not NetworkUtils.is_ok(response):
399
- print("Failed to get valid response from printer API")
399
+ logger.warning("Failed to get a valid response from the printer API.")
400
400
  return False
401
401
 
402
402
  # Parse machine info from detail response
403
403
  machine_info = MachineInfoParser.from_detail(response.detail)
404
404
  if not machine_info:
405
- print("Failed to parse machine info from detail response")
405
+ logger.warning("Failed to parse machine info from the /detail response.")
406
406
  return False
407
407
 
408
408
  # Detect http_only from the parsed model BEFORE touching TCP. The
@@ -426,16 +426,16 @@ class FlashForgeClient:
426
426
  ):
427
427
  self.is_pro = True
428
428
  else:
429
- print(
430
- "Warning: Unable to get PrinterInfo from TCP API, "
431
- "some features might not work"
429
+ logger.warning(
430
+ "Unable to get PrinterInfo from the TCP API; some features might "
431
+ "not work."
432
432
  )
433
433
 
434
434
  # Cache the details
435
435
  return self.cache_details(machine_info)
436
436
 
437
437
  except Exception as error:
438
- print(f"Error in verify_connection: {error}")
438
+ logger.warning("Error in verify_connection: %s", error)
439
439
  return False
440
440
 
441
441
  async def send_product_command(self) -> bool:
@@ -4,10 +4,13 @@ FlashForge Python API - Endstop Status Parser
4
4
  Parses endstop and machine status information from M119 command responses.
5
5
  """
6
6
 
7
+ import logging
7
8
  import re
8
9
  from enum import Enum
9
10
  from typing import Optional
10
11
 
12
+ logger = logging.getLogger(__name__)
13
+
11
14
  # Pre-compiled regex to match key:value pairs where value is an integer
12
15
  KV_PATTERN = re.compile(r"([A-Za-z0-9-]+):\s*(\d+)")
13
16
 
@@ -144,7 +147,7 @@ class EndstopStatus:
144
147
  elif "BUSY" in machine_status:
145
148
  self.machine_status = MachineStatus.BUSY
146
149
  else:
147
- print(f"EndstopStatus: Encountered unknown MachineStatus: {machine_status}")
150
+ logger.warning("EndstopStatus: unknown MachineStatus: %s", machine_status)
148
151
  self.machine_status = MachineStatus.DEFAULT
149
152
  elif line.startswith("MoveMode:"):
150
153
  move_mode = line.replace("MoveMode:", "", 1).strip().upper()
@@ -159,7 +162,7 @@ class EndstopStatus:
159
162
  elif "HOMING" in move_mode:
160
163
  self.move_mode = MoveMode.HOMING
161
164
  else:
162
- print(f"EndstopStatus: Encountered unknown MoveMode: {move_mode}")
165
+ logger.warning("EndstopStatus: unknown MoveMode: %s", move_mode)
163
166
  self.move_mode = MoveMode.DEFAULT
164
167
  elif line.startswith("Status "):
165
168
  self.status = Status(line)
@@ -184,9 +187,8 @@ class EndstopStatus:
184
187
  return self
185
188
 
186
189
  except Exception as e:
187
- print("Unable to create EndstopStatus instance from replay")
188
- print(f"Replay: {replay}")
189
- print(f"Error: {e}")
190
+ logger.warning("Unable to create an EndstopStatus from the reply: %s", e)
191
+ logger.debug("Reply that failed to parse: %s", replay)
190
192
  return None
191
193
 
192
194
  def is_print_complete(self) -> bool:
@@ -4,8 +4,11 @@ FlashForge Python API - Print Status Parser
4
4
  Parses print progress information from M27 command responses.
5
5
  """
6
6
 
7
+ import logging
7
8
  from typing import Optional
8
9
 
10
+ logger = logging.getLogger(__name__)
11
+
9
12
 
10
13
  class PrintStatus:
11
14
  """
@@ -69,13 +72,13 @@ class PrintStatus:
69
72
  return None
70
73
 
71
74
  if not self.sd_current or not self.sd_total:
72
- print("PrintStatus: Invalid SD progress format")
75
+ logger.warning("PrintStatus: invalid SD progress format.")
73
76
  return None
74
77
 
75
78
  return self
76
79
 
77
80
  except Exception as e:
78
- print(f"Error parsing print status: {e}")
81
+ logger.warning("Error parsing the print status: %s", e)
79
82
  return None
80
83
 
81
84
  def get_print_percent(self) -> float:
@@ -6,9 +6,12 @@ Handles the parsing, storage, and manipulation of 3D print file thumbnail images
6
6
 
7
7
  import asyncio
8
8
  import base64
9
+ import logging
9
10
  from pathlib import Path
10
11
  from typing import Optional
11
12
 
13
+ logger = logging.getLogger(__name__)
14
+
12
15
 
13
16
  class ThumbnailInfo:
14
17
  """
@@ -65,7 +68,7 @@ class ThumbnailInfo:
65
68
  # Find where the PNG data starts (after the "ok" text delimiter)
66
69
  ok_index = replay.find("ok")
67
70
  if ok_index == -1:
68
- print("ThumbnailInfo: No 'ok' found in response")
71
+ logger.warning("ThumbnailInfo: no 'ok' delimiter found in the response.")
69
72
  return None
70
73
 
71
74
  # Skip the 'ok' text and any immediately following control characters
@@ -87,11 +90,11 @@ class ThumbnailInfo:
87
90
  self._image_data = binary_buffer[png_start:]
88
91
  return self
89
92
  else:
90
- print("ThumbnailInfo: No PNG signature found in binary data")
93
+ logger.warning("ThumbnailInfo: no PNG signature found in the binary data.")
91
94
  return None
92
95
 
93
96
  except Exception as e:
94
- print(f"ThumbnailInfo: Error parsing response: {e}")
97
+ logger.warning("ThumbnailInfo: error parsing the response: %s", e)
95
98
  return None
96
99
 
97
100
  def get_image_data(self) -> str | None:
@@ -157,7 +160,7 @@ class ThumbnailInfo:
157
160
  True if the file was saved successfully, False otherwise
158
161
  """
159
162
  if not self._image_data:
160
- print("ThumbnailInfo: No image data to save")
163
+ logger.warning("ThumbnailInfo: no image data to save.")
161
164
  return False
162
165
 
163
166
  try:
@@ -169,18 +172,20 @@ class ThumbnailInfo:
169
172
  file_path = f"{base_name}.png"
170
173
 
171
174
  if not file_path:
172
- print("ThumbnailInfo: No file path provided and no filename to generate one from")
175
+ logger.warning(
176
+ "ThumbnailInfo: no file path provided and no filename to generate one from."
177
+ )
173
178
  return False
174
179
 
175
180
  # Write the bytes to file in a separate thread to avoid blocking the event loop
176
181
  loop = asyncio.get_running_loop()
177
182
  await loop.run_in_executor(None, self._write_file_sync, file_path, self._image_data)
178
183
 
179
- print(f"ThumbnailInfo: Saved thumbnail to {file_path}")
184
+ logger.debug("ThumbnailInfo: saved the thumbnail to %s", file_path)
180
185
  return True
181
186
 
182
187
  except Exception as e:
183
- print(f"ThumbnailInfo: Error saving thumbnail to file: {e}")
188
+ logger.warning("ThumbnailInfo: error saving the thumbnail to a file: %s", e)
184
189
  return False
185
190
 
186
191
  def save_to_file_sync(self, file_path: str | None = None) -> bool:
@@ -194,7 +199,7 @@ class ThumbnailInfo:
194
199
  True if the file was saved successfully, False otherwise
195
200
  """
196
201
  if not self._image_data:
197
- print("ThumbnailInfo: No image data to save")
202
+ logger.warning("ThumbnailInfo: no image data to save.")
198
203
  return False
199
204
 
200
205
  try:
@@ -206,18 +211,20 @@ class ThumbnailInfo:
206
211
  file_path = f"{base_name}.png"
207
212
 
208
213
  if not file_path:
209
- print("ThumbnailInfo: No file path provided and no filename to generate one from")
214
+ logger.warning(
215
+ "ThumbnailInfo: no file path provided and no filename to generate one from."
216
+ )
210
217
  return False
211
218
 
212
219
  # Write the bytes to file
213
220
  with open(file_path, "wb") as f:
214
221
  f.write(self._image_data)
215
222
 
216
- print(f"ThumbnailInfo: Saved thumbnail to {file_path}")
223
+ logger.debug("ThumbnailInfo: saved the thumbnail to %s", file_path)
217
224
  return True
218
225
 
219
226
  except Exception as e:
220
- print(f"ThumbnailInfo: Error saving thumbnail to file: {e}")
227
+ logger.warning("ThumbnailInfo: error saving the thumbnail to a file: %s", e)
221
228
  return False
222
229
 
223
230
  def get_image_size(self) -> tuple[int, int]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flashforge-python-api
3
- Version: 1.3.2
3
+ Version: 1.3.3
4
4
  Summary: A comprehensive Python library for controlling FlashForge 3D printers
5
5
  Project-URL: Homepage, https://github.com/GhostTypes/ff-5mp-api-py
6
6
  Project-URL: Documentation, https://github.com/GhostTypes/ff-5mp-api-py#readme
@@ -1,19 +1,20 @@
1
1
  flashforge/__init__.py,sha256=0PGa08cJ2RjAlIyJqX4MW3w1I-FUIg8ftaJ6I-A5cc0,5336
2
- flashforge/client.py,sha256=CwgRq58JZNuFSNdp3x49kUs6tyxD7KHJGifjPH4_oEw,21321
2
+ flashforge/client.py,sha256=nDtnv3y9XlEQknIkgIUiuOYAiyTEGbQ7tHBdCs2fg1I,21432
3
3
  flashforge/api/__init__.py,sha256=vQz-DkG6LTH39bI4fyiUc0D9jQzemLh45pORLptSOlg,335
4
4
  flashforge/api/constants/__init__.py,sha256=Q0HL2tqSBYPd4Oz49VHLS3qUvRuv__GCvTGecaLrQ-Y,163
5
5
  flashforge/api/constants/commands.py,sha256=XM2rooBESPDaywlZrfW2ECTaLLFaSKXkJ7FQ-RBmVdY,578
6
6
  flashforge/api/constants/endpoints.py,sha256=oZtOFfkU64THDeCMUIVv6_0L-BOUZzgLKAUfjHkUhi8,337
7
7
  flashforge/api/controls/__init__.py,sha256=53s-H25Pjwr0kvPk8MZ2Twy8VHGqGcO6Q6uBphhEOh4,293
8
- flashforge/api/controls/control.py,sha256=N1DGpuyvuPWzyMd4_CvbyaQQwoacPIRE8yBaFyy0d9g,14715
9
- flashforge/api/controls/creator5_palette.py,sha256=41weYaGnnPp5uqKzPixh4whCCo8ZyH9ZKYarzozfMUI,8774
10
- flashforge/api/controls/files.py,sha256=VhzZaCZ2_dTxHH6AHyhTutklERPSF3ymq3z_qcKpNIw,7848
11
- flashforge/api/controls/info.py,sha256=WfLeO_BZbqkD2kPXM9cTro7ZOCmn7sxOnd0g6QnA-aw,17559
12
- flashforge/api/controls/job_control.py,sha256=pO0D0YD_DCfsAwv196XCfrw-xarGZcYkrdxq11wAZEM,31955
13
- flashforge/api/controls/temp_control.py,sha256=eFMvuBJDWia_zQ5k8v2gKR0AsLtGohZao2sZBSZKgOg,12835
8
+ flashforge/api/controls/control.py,sha256=OgE7K6NkFLwFN1r_q5g1RPwYC8MCt0f6L5CZxDW44d8,15151
9
+ flashforge/api/controls/creator5_palette.py,sha256=zdD3PIXx7hCVj2Fmv1CSFOaqogRUPeBaymgY2DFiMxk,8835
10
+ flashforge/api/controls/files.py,sha256=25yxln9NROas9TtFMZR9e8LtA8ruk8RUu2C81vpNgTk,8043
11
+ flashforge/api/controls/info.py,sha256=jvP4mCkywfV79Uz_z1uJ7qWvEx0MwRoB6ziuvwps_YU,18206
12
+ flashforge/api/controls/job_control.py,sha256=NnACm4LPUz4n8lnRiUl33jilWekS1b6HQTbP--nDzZc,33225
13
+ flashforge/api/controls/temp_control.py,sha256=EOO5TkODirjFcnQ7rZrirxAweHBdVin032AWj-Z8VQs,13040
14
14
  flashforge/api/filament/__init__.py,sha256=isT2dl0hzUS0xMTXMm4Tip0GTG1gCsm8LKWa9xPu4Y8,104
15
15
  flashforge/api/filament/filament.py,sha256=TtcC2G2nD9Sq4cOrCZzFLZ6Q0Ve-zfrfuEF9uQ8M3eE,1214
16
16
  flashforge/api/misc/__init__.py,sha256=1AG7vOMRBZQtHp6QUzfr9alcJPeNv3JpU44NrznxDHs,211
17
+ flashforge/api/misc/redaction.py,sha256=8LG2WsFXhdBqbZ4AXYwpm8hQ_PxaOeAKmZBeG5kzAT4,2675
17
18
  flashforge/api/misc/scientific_notation.py,sha256=KSMTrrYhPWLv0gbcEAD6u-jEOcIwUjOCOPMBi0LiO-k,826
18
19
  flashforge/api/misc/temperature.py,sha256=qeHlCdPzLyqGDEB874Ib5AgYHMR9Qtw61shnw4G095E,1066
19
20
  flashforge/api/network/__init__.py,sha256=G5fBoAlq-NQPDkWp579mFIqsj0v7B1Lb2TAzO6IZA0Q,164
@@ -34,14 +35,14 @@ flashforge/tcp/gcode/a3_gcode_controller.py,sha256=x84_xo_Ll5k6hMAwDWuNUo54znV-B
34
35
  flashforge/tcp/gcode/gcode_controller.py,sha256=6K1VGgX59AcE_Kh_gm13dmxCY_kuzV_d2ooSpheONRI,10075
35
36
  flashforge/tcp/gcode/gcodes.py,sha256=55Ii1JpJNJNmOnBivyF83vz3TZUPDtp01lX72NINfPQ,3658
36
37
  flashforge/tcp/parsers/__init__.py,sha256=kDaJmiH0JcR5i693Fj3pwmINLWN0ipirN3LWGFvPst0,730
37
- flashforge/tcp/parsers/endstop_status.py,sha256=4OWwE5cyBFhmTPmp18hU4lRr6YSA7sm7U13pHxI1ZJ0,9845
38
+ flashforge/tcp/parsers/endstop_status.py,sha256=k7C5P_TiKlqBxSq2ybRoBDaaDcQ_EgiZe2NLYo7ubiE,9902
38
39
  flashforge/tcp/parsers/location_info.py,sha256=0fbqCmQ2EQQe8z9fiX0zP1YsnuVayTs81zmF3_HuGZM,2895
39
- flashforge/tcp/parsers/print_status.py,sha256=GdmRvkQKMq-JdefsateMYdlTrlEB16wm29DNUQAbiXA,6194
40
+ flashforge/tcp/parsers/print_status.py,sha256=C-KzukhK0TR86AP3t3XrMH4dief6pa3I-yiST4rL0mk,6271
40
41
  flashforge/tcp/parsers/printer_info.py,sha256=CHPs6nJfEByXyG3co7Nb8_ygggxkYuc7A9O9bpQEqXE,5441
41
42
  flashforge/tcp/parsers/temp_info.py,sha256=9wRGUM9cKvDiZD3SIxBx_qVLZbeXoYsoY3-R3vN3H4g,7938
42
- flashforge/tcp/parsers/thumbnail_info.py,sha256=11MXltBg3HphSOATVt4mDcyETkHlnL6ngkXJ7KevviY,10201
43
- flashforge_python_api-1.3.2.dist-info/METADATA,sha256=K_4JvhnuupxESwEv9qzBpv-kHk7v7erICdSewlijVYE,4970
44
- flashforge_python_api-1.3.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
45
- flashforge_python_api-1.3.2.dist-info/entry_points.txt,sha256=AkOxlsLvQ7cvMLxn7tlzfKp_DCH2hXhbVceHIXxawpU,66
46
- flashforge_python_api-1.3.2.dist-info/licenses/LICENSE,sha256=-cTA-hrmvlb3pqlQrBZQXUnKayhUjsLJMPb7TD91frM,1067
47
- flashforge_python_api-1.3.2.dist-info/RECORD,,
43
+ flashforge/tcp/parsers/thumbnail_info.py,sha256=1U1S_gcIZ2lDpEA4622ywUew6TVw0BnzjlBAiihQwzk,10478
44
+ flashforge_python_api-1.3.3.dist-info/METADATA,sha256=UpYv26NPSHt5nqH57396UOE9s-JcirHPuNsD8e1afBM,4970
45
+ flashforge_python_api-1.3.3.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
46
+ flashforge_python_api-1.3.3.dist-info/entry_points.txt,sha256=AkOxlsLvQ7cvMLxn7tlzfKp_DCH2hXhbVceHIXxawpU,66
47
+ flashforge_python_api-1.3.3.dist-info/licenses/LICENSE,sha256=-cTA-hrmvlb3pqlQrBZQXUnKayhUjsLJMPb7TD91frM,1067
48
+ flashforge_python_api-1.3.3.dist-info/RECORD,,