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.
@@ -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: