flashforge-python-api 1.3.5__py3-none-any.whl → 1.5.0__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
@@ -128,7 +128,7 @@ from .tcp import (
128
128
  )
129
129
 
130
130
  FiveMClient = FlashForgeClient
131
- __version__ = "1.3.5"
131
+ __version__ = "1.5.0"
132
132
  __author__ = "FlashForge Python API Contributors"
133
133
  __email__ = "notghosttypes@gmail.com"
134
134
  __description__ = "Python library for controlling FlashForge 3D printers"
@@ -11,5 +11,5 @@ class Commands:
11
11
  TEMP_CONTROL_CMD = "temperatureCtl_cmd"
12
12
  # AD5X + Creator 5 material-station slot metadata (name + color). Filament
13
13
  # load/unload (`ms_cmd`) stays AD5X-only — the Creator 5 firmware has no
14
- # `ms_cmd` (confirmed via Ghidra RE).
14
+ # `ms_cmd` (verified in the firmware).
15
15
  MATERIAL_STATION_CONFIG_CMD = "msConfig_cmd"
@@ -237,8 +237,8 @@ class Control:
237
237
  This information is shown on the printer UI and used for print validation; it
238
238
  does not move any filament. Available on the AD5X and the Creator 5 / Creator 5 Pro.
239
239
 
240
- The ``msConfig_cmd`` handler is firmware-confirmed present on the Creator 5
241
- (Ghidra RE of ``firmwareExe`` 1.9.2). The Creator 5 has no removable IFS; it
240
+ The ``msConfig_cmd`` handler is confirmed present on the Creator 5
241
+ (verified in firmware 1.9.2). The Creator 5 has no removable IFS; it
242
242
  surfaces its 4 tool heads as the 4 "slots", so this sets per-tool material
243
243
  metadata. Both models share the same ``OrcaServer`` command path and wire
244
244
  format. Note that **filament load/unload (``slotAction`` / ``ms_cmd``)
@@ -303,24 +303,21 @@ class Control:
303
303
  logger.debug("Sending control command %s with args %s", command, args)
304
304
 
305
305
  try:
306
- await self.client.is_http_client_busy()
307
-
308
306
  session = await self.client.get_http_session()
309
- async with session.post(
310
- self.client.get_endpoint(Endpoints.CONTROL),
311
- json=payload,
312
- headers={"Content-Type": "application/json"},
313
- ) as response:
314
- data = await json_from_response(response)
315
- logger.debug("Control command %s reply: %s", command, data)
307
+ async with self.client.command_lock:
308
+ async with session.post(
309
+ self.client.get_endpoint(Endpoints.CONTROL),
310
+ json=payload,
311
+ headers={"Content-Type": "application/json"},
312
+ ) as response:
313
+ data = await json_from_response(response)
314
+ logger.debug("Control command %s reply: %s", command, data)
316
315
 
317
- return NetworkUtils.is_ok(data)
316
+ return NetworkUtils.is_ok(data)
318
317
 
319
318
  except Exception as e:
320
319
  logger.warning("Error in send_control_command (%s): %s", command, e)
321
320
  return False
322
- finally:
323
- self.client.release_http_client()
324
321
 
325
322
  async def send_job_control_cmd(self, command: str) -> bool:
326
323
  """
@@ -4,8 +4,8 @@ palette and perceptual nearest-color snapping.
4
4
 
5
5
  The Creator 5 ``msConfig_cmd`` only renders a color icon when the ``rgb`` field is
6
6
  an EXACT, case-sensitive, byte-for-byte match against one of the firmware's 24
7
- built-in palette strings (compared via ``std::operator==`` in ``firmwareExe``
8
- 1.9.2). A non-match leaves the slot's color index at 0 (White). These values
7
+ built-in palette strings (verified in firmware 1.9.2). A non-match leaves the
8
+ slot's color index at 0 (White). These values
9
9
  DIFFER from the AD5X palette (e.g. Blue is ``#4CAAF8`` here vs ``#45A8F9`` on the
10
10
  AD5X), so callers must snap against THIS list specifically.
11
11
 
@@ -43,7 +43,7 @@ class Creator5PaletteColor:
43
43
  """Wire value sent to the printer, always uppercase ``#RRGGBB``."""
44
44
 
45
45
 
46
- #: The firmware's 24-entry UI palette (firmwareExe 1.9.2, Ghidra-confirmed).
46
+ #: The firmware's 24-entry UI palette (verified in firmware 1.9.2).
47
47
  #: Index 0 (White) is also what the firmware falls back to on a no-match.
48
48
  CREATOR5_PALETTE: tuple[Creator5PaletteColor, ...] = (
49
49
  Creator5PaletteColor(0, "White", "#FFFFFF"),
@@ -78,10 +78,9 @@ class Files:
78
78
 
79
79
  The Creator 5 is the surprise there, and it is firmware, not a parsing gap:
80
80
  it is a newer printer than the AD5X but reports less (confirmed against a
81
- Creator 5 Pro, 2026-08-05). Do not describe this method as "AD5X and newer" -
82
- that phrasing cost a downstream integration three releases of chasing a bug
83
- that was never in the code. Callers that need per-tool data on a Creator 5
84
- must parse the 3mf themselves at upload time.
81
+ live Creator 5 Pro, 2026-08-05). Do not describe this method as "AD5X and
82
+ newer" - only the AD5X answers with `gcodeListDetail`. Callers that need
83
+ per-tool data on a Creator 5 must parse the 3mf themselves at upload time.
85
84
 
86
85
  Returns:
87
86
  A list of FFGcodeFileEntry objects. Returns an empty list if the request fails or an error occurs.
@@ -46,6 +46,19 @@ PID_MODEL_NAMES: dict[int, str] = {
46
46
  }
47
47
 
48
48
 
49
+ # The one state in which the firmware actually counts `estimatedTime` down.
50
+ # Outside it the field freezes at its last value while the wall clock keeps
51
+ # moving, so `now() + estimatedTime` walks forward one minute per minute instead
52
+ # of holding still - a paused print appears to recede forever. The duration
53
+ # stays correct throughout; only its conversion to an absolute timestamp is
54
+ # invalid, which is why `print_eta` is ungated and `completion_time` is not.
55
+ #
56
+ # HEATING is deliberately excluded. The pre-print warmup does not advance the
57
+ # job either, so the same drift applies - it just lasts minutes rather than
58
+ # hours, which is why it is easy to miss.
59
+ _ADVANCING_STATES = frozenset({MachineState.PRINTING})
60
+
61
+
49
62
  class MachineInfoParser:
50
63
  """
51
64
  Transforms printer detail data from the API response format into a structured FFMachineInfo object.
@@ -75,7 +88,14 @@ class MachineInfoParser:
75
88
  formatted_run_time = MachineInfoParser._format_time_from_seconds(
76
89
  getattr(detail, "print_duration", 0) or 0
77
90
  )
78
- completion_time = datetime.now() + timedelta(seconds=estimated_time)
91
+ machine_state = MachineInfoParser._get_machine_state(
92
+ getattr(detail, "status", "") or ""
93
+ )
94
+ completion_time = (
95
+ datetime.now() + timedelta(seconds=estimated_time)
96
+ if machine_state in _ADVANCING_STATES
97
+ else None
98
+ )
79
99
 
80
100
  total_minutes = getattr(detail, "cumulative_print_time", 0) or 0
81
101
  hours = total_minutes // 60
@@ -255,9 +275,7 @@ class MachineInfoParser:
255
275
  print_speed_adjust=getattr(detail, "print_speed_adjust", 0) or 0,
256
276
  filament_type=getattr(detail, "right_filament_type", "") or "",
257
277
  # Machine state
258
- machine_state=MachineInfoParser._get_machine_state(
259
- getattr(detail, "status", "") or ""
260
- ),
278
+ machine_state=machine_state,
261
279
  status=getattr(detail, "status", "") or "",
262
280
  total_print_layers=getattr(detail, "target_print_layer", 0) or 0,
263
281
  tvoc=getattr(detail, "tvoc", 0) or 0,
@@ -386,7 +404,7 @@ class Info:
386
404
  if machine_info is None:
387
405
  # We validated the payload but could not build our own model from
388
406
  # it - that is a bug on our side, not an unreachable printer, and
389
- # reporting it as the latter is what issue #18 was about.
407
+ # reporting it as the latter is what ff-5mp-hass#18 was about.
390
408
  raise FlashForgeResponseError(
391
409
  "The /detail payload was read but could not be converted into machine info",
392
410
  endpoint=Endpoints.DETAIL,
@@ -444,7 +462,7 @@ class Info:
444
462
  This exists so callers can inspect identity fields - above all `pid` -
445
463
  without first having to successfully validate the ~50 unrelated fields
446
464
  in the payload. A supported printer must never be rejected because of a
447
- field that has nothing to do with whether it is supported (issue #18).
465
+ field that has nothing to do with whether it is supported (ff-5mp-hass#18).
448
466
 
449
467
  Returns:
450
468
  The decoded response body, or None if the printer could not be
@@ -253,16 +253,17 @@ class JobControl:
253
253
 
254
254
  try:
255
255
  session = await self.client.get_http_session()
256
- async with session.post(
257
- self.client.get_endpoint(Endpoints.GCODE_PRINT),
258
- json=payload,
259
- headers={"Content-Type": "application/json"},
260
- ) as response:
261
- if response.status != 200:
262
- return False
256
+ async with self.client.command_lock:
257
+ async with session.post(
258
+ self.client.get_endpoint(Endpoints.GCODE_PRINT),
259
+ json=payload,
260
+ headers={"Content-Type": "application/json"},
261
+ ) as response:
262
+ if response.status != 200:
263
+ return False
263
264
 
264
- result = await json_from_response(response)
265
- return NetworkUtils.is_ok(result)
265
+ result = await json_from_response(response)
266
+ return NetworkUtils.is_ok(result)
266
267
 
267
268
  except Exception as error:
268
269
  logger.warning("print_local_file error: %s", error)
@@ -422,16 +423,17 @@ class JobControl:
422
423
 
423
424
  try:
424
425
  session = await self.client.get_http_session()
425
- async with session.post(
426
- self.client.get_endpoint(Endpoints.GCODE_PRINT),
427
- json=payload,
428
- headers={"Content-Type": "application/json"},
429
- ) as response:
430
- if response.status != 200:
431
- return False
426
+ async with self.client.command_lock:
427
+ async with session.post(
428
+ self.client.get_endpoint(Endpoints.GCODE_PRINT),
429
+ json=payload,
430
+ headers={"Content-Type": "application/json"},
431
+ ) as response:
432
+ if response.status != 200:
433
+ return False
432
434
 
433
- result = await json_from_response(response)
434
- return NetworkUtils.is_ok(result)
435
+ result = await json_from_response(response)
436
+ return NetworkUtils.is_ok(result)
435
437
 
436
438
  except Exception as error:
437
439
  logger.warning("AD5X multi-color job error: %s", error)
@@ -474,40 +476,59 @@ class JobControl:
474
476
 
475
477
  try:
476
478
  session = await self.client.get_http_session()
477
- async with session.post(
478
- self.client.get_endpoint(Endpoints.GCODE_PRINT),
479
- json=payload,
480
- headers={"Content-Type": "application/json"},
481
- ) as response:
482
- if response.status != 200:
483
- return False
479
+ async with self.client.command_lock:
480
+ async with session.post(
481
+ self.client.get_endpoint(Endpoints.GCODE_PRINT),
482
+ json=payload,
483
+ headers={"Content-Type": "application/json"},
484
+ ) as response:
485
+ if response.status != 200:
486
+ return False
484
487
 
485
- result = await json_from_response(response)
486
- return NetworkUtils.is_ok(result)
488
+ result = await json_from_response(response)
489
+ return NetworkUtils.is_ok(result)
487
490
 
488
491
  except Exception as error:
489
492
  logger.warning("AD5X single-color job error: %s", error)
490
493
  raise error
491
494
 
492
495
  # --- Creator 5 / Creator 5 Pro ---
493
- # The Creator 5 splits the material-station workflow across two requests,
494
- # unlike the AD5X (which maps materials at upload time). On the C5:
495
- # 1. Upload the file (POST /uploadGcode). The firmware reads `useMatlStation`
496
- # and `gcodeToolCnt` here to register the file as a multi-tool job. There
497
- # is NO `firstLayerInspection` header (the field doesn't exist on the
498
- # C5), and the booleans are checked as the string "true"/"false".
499
- # 2. Start the print (POST /printGcode) with the per-tool
500
- # `materialMappings`. See start_creator5_job.
496
+ # The Creator 5 firmware applies material mappings in two places:
497
+ # - POST /printGcode reads `materialMappings` for any file already on the
498
+ # printer (see start_creator5_job). This is the recommended path: upload
499
+ # with start_print=False, then start with the mappings.
500
+ # - POST /uploadGcode also reads a base64 `materialMappings` header, the
501
+ # same format as the AD5X, and applies it when `printNow` is true.
502
+ # The firmware only logs `useMatlStation` and `gcodeToolCnt` on upload; they
503
+ # have no effect. There is NO `firstLayerInspection` header (the field doesn't
504
+ # exist on the C5), and the booleans are checked as the string "true"/"false".
505
+ #
506
+ # Mappings only take effect for a .3mf file. Without mappings, tool N prints
507
+ # from the slot with the slicer's filament number (filament 1 -> slot 1),
508
+ # whatever is loaded there. Send a mapping for every tool, including a
509
+ # single-tool print.
501
510
 
502
511
  async def upload_file_creator5(self, params: Creator5UploadParams) -> bool:
503
512
  """
504
513
  Uploads a file (.gcode or .3mf) to a Creator 5 / Creator 5 Pro via
505
514
  ``POST /uploadGcode``, with the C5-specific material-station headers.
506
515
 
507
- Unlike :meth:`upload_file_ad5x` this sends no ``firstLayerInspection``
508
- header (the C5 has no such field) and no ``materialMappings`` header (the
509
- C5 maps materials at print-start, not upload). Booleans are sent as the
510
- string "true"/"false".
516
+ Recommended flow: upload with ``start_print=False``, then call
517
+ :meth:`start_creator5_job` with a mapping for every tool. To start in one
518
+ request instead, pass ``material_mappings`` with ``start_print=True``: the
519
+ firmware reads them from a base64 ``materialMappings`` header, like the
520
+ AD5X.
521
+
522
+ ``material_mappings`` is sent only when ``start_print`` is true. The
523
+ firmware keeps upload mappings in memory until the next print ends, so
524
+ mappings on an upload that does not start would apply to a later,
525
+ unrelated print.
526
+
527
+ ``useMatlStation`` and ``gcodeToolCnt`` are sent for parity with
528
+ FlashForge's own clients; the firmware only logs them. Unlike
529
+ :meth:`upload_file_ad5x` this sends no ``firstLayerInspection`` header
530
+ (the C5 has no such field). Booleans are sent as the string
531
+ "true"/"false".
511
532
 
512
533
  Args:
513
534
  params: Creator 5 upload parameters.
@@ -537,8 +558,7 @@ class JobControl:
537
558
 
538
559
  try:
539
560
  # C5 upload headers. No firstLayerInspection (absent on the C5);
540
- # booleans sent as "true"/"false" (firmware checks for "true"); no
541
- # materialMappings header (C5 maps at print-start, not upload).
561
+ # booleans sent as "true"/"false" (firmware checks for "true").
542
562
  custom_headers = {
543
563
  "serialNumber": self.client.serial_number,
544
564
  "checkCode": self.client.check_code,
@@ -552,6 +572,20 @@ class JobControl:
552
572
  "Expect": "100-continue",
553
573
  }
554
574
 
575
+ mappings = params.material_mappings or []
576
+ if mappings:
577
+ if not params.start_print:
578
+ logger.warning(
579
+ "upload_file_creator5: material_mappings ignored because start_print "
580
+ "is False; pass them to start_creator5_job instead."
581
+ )
582
+ else:
583
+ if not self._validate_creator5_material_mappings(mappings):
584
+ return False
585
+ custom_headers["materialMappings"] = self._encode_material_mappings_to_base64(
586
+ mappings
587
+ )
588
+
555
589
  logger.debug("Creator 5 upload request headers: %s", redact_mapping(custom_headers))
556
590
 
557
591
  # Create multipart form data
@@ -600,13 +634,17 @@ class JobControl:
600
634
  """
601
635
  Starts a local print on a Creator 5 / Creator 5 Pro via ``POST /printGcode``.
602
636
 
603
- This is the Creator 5's print-start material-matching command (distinct
604
- from the AD5X, which maps materials at upload time). The file must already
605
- be on the printer. Provide ``material_mappings`` for a multi-tool print, or
606
- omit them for a single-tool print. Sends only the fields the Creator 5
607
- firmware reads: NO ``useMatlStation`` / ``gcodeToolCnt`` (those live on the
608
- upload) and NO ``firstLayerInspection`` (doesn't exist on the C5).
609
- ``flowCalibration`` and ``timeLapseVideo`` are always present (default False).
637
+ This is the Creator 5's print-start material-matching command. The file
638
+ must already be on the printer; it can be a file uploaded long before, not
639
+ only the last upload. Sends only the fields the Creator 5 firmware reads:
640
+ NO ``useMatlStation`` / ``gcodeToolCnt`` and NO ``firstLayerInspection``
641
+ (doesn't exist on the C5). ``flowCalibration`` and ``timeLapseVideo`` are
642
+ always present (default False).
643
+
644
+ Provide a mapping for every tool, including a single-tool print. Without
645
+ mappings the firmware prints each tool from the slot with the slicer's
646
+ filament number (filament 1 -> slot 1), whatever is loaded there.
647
+ Mappings take effect only for a .3mf file.
610
648
 
611
649
  Args:
612
650
  params: File name, leveling flag, and optional flags / material mappings.
@@ -653,16 +691,17 @@ class JobControl:
653
691
 
654
692
  try:
655
693
  session = await self.client.get_http_session()
656
- async with session.post(
657
- self.client.get_endpoint(Endpoints.GCODE_PRINT),
658
- json=payload,
659
- headers={"Content-Type": "application/json"},
660
- ) as response:
661
- if response.status != 200:
662
- return False
694
+ async with self.client.command_lock:
695
+ async with session.post(
696
+ self.client.get_endpoint(Endpoints.GCODE_PRINT),
697
+ json=payload,
698
+ headers={"Content-Type": "application/json"},
699
+ ) as response:
700
+ if response.status != 200:
701
+ return False
663
702
 
664
- result = await json_from_response(response)
665
- return NetworkUtils.is_ok(result)
703
+ result = await json_from_response(response)
704
+ return NetworkUtils.is_ok(result)
666
705
 
667
706
  except Exception as error:
668
707
  logger.warning("Creator 5 job error: %s", error)
@@ -27,12 +27,12 @@ TEMP_OFF = -100
27
27
  # Value that turns a tool/nozzle OFF inside the `nozzles` array. Unlike the scalar
28
28
  # heater fields (which accept TEMP_OFF = -100), the Creator 5 firmware's
29
29
  # per-nozzle parser only treats a literal 0 as "off" — it ignores -100 in the
30
- # `nozzles` array and the tool keeps heating. (Firmware-confirmed via tester
31
- # report; this is the v1.6.1 nozzle-off bugfix.)
30
+ # `nozzles` array and the tool keeps heating (observed on live hardware;
31
+ # this is the v1.6.1 nozzle-off bugfix).
32
32
  NOZZLE_OFF = 0
33
33
  # Number of tool/nozzle entries the Creator 5 firmware requires in the
34
34
  # `nozzles` array. The firmware ignores the array unless its length is exactly
35
- # this (confirmed via Ghidra: `size() == 4` check in the temp parser).
35
+ # this (verified in the firmware).
36
36
  NOZZLE_COUNT = 4
37
37
 
38
38
 
@@ -188,7 +188,7 @@ class TempControl:
188
188
  """
189
189
  if self.client.http_only:
190
190
  # Creator 5 tools are driven ONLY via the `nozzles` array — the
191
- # firmware's doTemperatureControl handler never reads
191
+ # firmware's temperature-control handler never reads
192
192
  # rightNozzle/leftNozzle. Target the primary tool (T0): the active
193
193
  # tool isn't reliably known over HTTP, so callers that need a
194
194
  # specific tool should use set_tool_temp(index, temp).
flashforge/client.py CHANGED
@@ -75,6 +75,15 @@ class FlashForgeClient:
75
75
  self._http_client_event = asyncio.Event()
76
76
  self._http_client_event.set() # Not busy initially
77
77
 
78
+ # FIFO mutex for command submission. asyncio.Lock hands the lock to
79
+ # waiters in the order they asked for it. So command POSTs run one at
80
+ # a time, in submission order. Scope: command POSTs only (/control,
81
+ # /product, /printGcode). File uploads (/uploadGcode) and read/poll
82
+ # POSTs (/detail, /gcodeList, /gcodeThumb) stay outside the lock. An
83
+ # upload can run for minutes; pause and stop must never queue behind
84
+ # one.
85
+ self._command_lock = asyncio.Lock()
86
+
78
87
  # TCP client setup
79
88
  tcp_options = None
80
89
  if options and options.tcp_port is not None:
@@ -202,6 +211,19 @@ class FlashForgeClient:
202
211
  self._http_session = aiohttp.ClientSession(timeout=timeout, headers={"Accept": "*/*"})
203
212
  return self._http_session
204
213
 
214
+ @property
215
+ def command_lock(self) -> asyncio.Lock:
216
+ """
217
+ The FIFO lock that serializes command-submission HTTP POSTs.
218
+
219
+ Command POSTs (/control, /product, /printGcode) hold this lock while
220
+ the request is in flight. asyncio.Lock serves waiters in arrival order,
221
+ so concurrent commands run one at a time, first come first served.
222
+ Uploads and read/poll requests never take this lock. So a long upload
223
+ cannot delay a pause or stop command.
224
+ """
225
+ return self._command_lock
226
+
205
227
  async def initialize(self) -> bool:
206
228
  """
207
229
  Initializes the FlashForgeClient and verifies the connection to the printer.
@@ -442,43 +464,42 @@ class FlashForgeClient:
442
464
  """
443
465
  Sends a product command to the printer to retrieve control states.
444
466
 
445
- This method sets the http_client_busy flag while the request is in progress.
467
+ This method holds the command lock while the request is in progress.
446
468
 
447
469
  Returns:
448
470
  True if the product command is sent successfully and valid data is received,
449
471
  False otherwise
450
472
  """
451
- self._http_client_busy = True
452
-
453
473
  payload = {"serialNumber": self.serial_number, "checkCode": self.check_code}
454
474
 
455
475
  try:
456
476
  session = await self._ensure_http_session()
457
- async with session.post(
458
- self.get_endpoint(Endpoints.PRODUCT),
459
- json=payload,
460
- headers={"Content-Type": "application/json"},
461
- ) as response:
462
- if response.status != 200:
463
- return False
464
-
465
- data = await json_from_response(response)
466
-
467
- # Validate response structure
468
- if not NetworkUtils.is_ok(data):
469
- return False
470
-
471
- # Parse product response and set control states
472
- product_response = ProductResponse(**data)
473
- if product_response and product_response.product:
474
- product = product_response.product
475
- self.product_info = product
476
- self._detected_led_control = product.lightCtrlState != 0
477
- self._detected_filtration_control = not (
478
- product.internalFanCtrlState == 0 or product.externalFanCtrlState == 0
479
- )
480
- self._apply_feature_overrides()
481
- return True
477
+ async with self._command_lock:
478
+ async with session.post(
479
+ self.get_endpoint(Endpoints.PRODUCT),
480
+ json=payload,
481
+ headers={"Content-Type": "application/json"},
482
+ ) as response:
483
+ if response.status != 200:
484
+ return False
485
+
486
+ data = await json_from_response(response)
487
+
488
+ # Validate response structure
489
+ if not NetworkUtils.is_ok(data):
490
+ return False
491
+
492
+ # Parse product response and set control states
493
+ product_response = ProductResponse(**data)
494
+ if product_response and product_response.product:
495
+ product = product_response.product
496
+ self.product_info = product
497
+ self._detected_led_control = product.lightCtrlState != 0
498
+ self._detected_filtration_control = not (
499
+ product.internalFanCtrlState == 0 or product.externalFanCtrlState == 0
500
+ )
501
+ self._apply_feature_overrides()
502
+ return True
482
503
 
483
504
  except ValidationError as error:
484
505
  # Distinct from a rejected check code, and it must not read as one:
@@ -496,8 +517,6 @@ class FlashForgeClient:
496
517
  except Exception as error:
497
518
  logger.warning("Error in send_product_command: %s", error)
498
519
  return False
499
- finally:
500
- self._http_client_busy = False
501
520
 
502
521
  return False
503
522
 
flashforge/exceptions.py CHANGED
@@ -15,7 +15,7 @@ class FlashForgeResponseError(FlashForgeError):
15
15
 
16
16
  This is deliberately distinct from a ``None`` return, which every HTTP
17
17
  control method uses for "could not reach the printer / the printer refused
18
- us". Collapsing the two is what made issue #18 take three releases to
18
+ us". Collapsing the two is what made ff-5mp-hass#18 take three releases to
19
19
  diagnose: a Creator 5 reporting ``chamberTemp: -108`` (the firmware's "no
20
20
  chamber sensor" sentinel) surfaced in Home Assistant as ``cannot_connect``,
21
21
  pointing the user at their network for a schema problem.
@@ -20,7 +20,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida
20
20
  # blast radius: pydantic fails a model as a unit, and `Info.get_detail_response`
21
21
  # turns any failure into "no data", which Home Assistant shows as an offline
22
22
  # printer. So a `ge=0` on a field nobody reads could take the whole integration
23
- # down. That is exactly what happened in issue #18, where a Creator 5 reporting
23
+ # down. That is exactly what happened in ff-5mp-hass#18, where a Creator 5 reporting
24
24
  # `chamberTemp: -108` made every entity unavailable and the config flow report
25
25
  # `cannot_connect`.
26
26
  #
@@ -369,7 +369,7 @@ class FFPrinterDetail(BaseModel):
369
369
  z_axis_compensation: float | None = Field(default=None, alias="zAxisCompensation")
370
370
 
371
371
  # Firmware reports absent temperature hardware with a negative sentinel
372
- # (-108 on a chamber-less Creator 5, issue #18) rather than by omitting the
372
+ # (-108 on a chamber-less Creator 5, ff-5mp-hass#18) rather than by omitting the
373
373
  # field. Map those to None here so "no sensor" reads as "not reported"
374
374
  # everywhere downstream, instead of as a -108 C reading or a hard failure.
375
375
  @field_validator(
@@ -514,7 +514,12 @@ class FFMachineInfo(BaseModel):
514
514
 
515
515
  # Extras
516
516
  print_eta: str = "00:00"
517
- completion_time: datetime = Field(default_factory=datetime.now)
517
+ # None whenever the print is not advancing (paused, heating, ready, error,
518
+ # ...). The firmware freezes `estimatedTime` outside the PRINTING state, so
519
+ # an absolute timestamp derived from it would recede in real time rather
520
+ # than hold. Use `print_eta` / `estimated_time` for the remaining duration,
521
+ # which stays valid in every state.
522
+ completion_time: datetime | None = None
518
523
  formatted_run_time: str = "00:00"
519
524
  formatted_total_run_time: str = "0h:0m"
520
525
 
@@ -175,12 +175,12 @@ class AD5XUploadParams(BaseModel):
175
175
  class Creator5JobParams(BaseModel):
176
176
  """Parameters for starting a Creator 5 / Creator 5 Pro local print job.
177
177
 
178
- Distinct from the AD5X job params: the Creator 5 maps materials at print-start
179
- (POST /printGcode) rather than upload time, so the body carries NO
180
- ``useMatlStation`` / ``gcodeToolCnt`` / ``firstLayerInspection`` (the latter
181
- doesn't exist on the C5). ``flowCalibration`` and ``timeLapseVideo`` are always
182
- present (default False); ``material_mappings`` is optional for a single-tool
183
- print.
178
+ The body carries NO ``useMatlStation`` / ``gcodeToolCnt`` /
179
+ ``firstLayerInspection`` (the latter doesn't exist on the C5).
180
+ ``flowCalibration`` and ``timeLapseVideo`` are always present (default False).
181
+ Map every tool, including a single-tool print: without mappings the firmware
182
+ prints each tool from the slot with the slicer's filament number (filament 1
183
+ -> slot 1). Mappings take effect only for a .3mf file.
184
184
  """
185
185
 
186
186
  model_config = ConfigDict(extra="forbid", populate_by_name=True)
@@ -196,16 +196,18 @@ class Creator5JobParams(BaseModel):
196
196
  material_mappings: list[AD5XMaterialMapping] | None = Field(
197
197
  default=None,
198
198
  max_length=4,
199
- description="Optional per-tool material mappings (1-4 items); omit for single-tool",
199
+ description="Per-tool material mappings (1-4 items); recommended for every print",
200
200
  )
201
201
 
202
202
 
203
203
  class Creator5UploadParams(BaseModel):
204
204
  """Parameters for uploading a file to a Creator 5 / Creator 5 Pro.
205
205
 
206
- Mirrors the AD5X upload but omits ``firstLayerInspection`` (absent on the C5)
207
- and the ``materialMappings`` header (the C5 maps materials at print-start, not
208
- upload). The C5 firmware checks the booleans as the string "true"/"false".
206
+ Mirrors the AD5X upload but omits ``firstLayerInspection`` (absent on the
207
+ C5). ``material_mappings`` is sent as the base64 ``materialMappings`` header
208
+ only when ``start_print`` is true. The firmware only logs ``use_matl_station``
209
+ and ``gcode_tool_cnt``. The C5 firmware checks the booleans as the string
210
+ "true"/"false".
209
211
  """
210
212
 
211
213
  model_config = ConfigDict(extra="forbid", populate_by_name=True)
@@ -223,6 +225,11 @@ class Creator5UploadParams(BaseModel):
223
225
  gcode_tool_cnt: int = Field(
224
226
  ge=1, le=4, description="Number of tools in the G-code (1-4 for the C5)"
225
227
  )
228
+ material_mappings: list[AD5XMaterialMapping] | None = Field(
229
+ default=None,
230
+ max_length=4,
231
+ description="Optional per-tool material mappings, sent only when start_print is true",
232
+ )
226
233
 
227
234
 
228
235
  class GCodeListResponse(GenericResponse):
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: flashforge-python-api
3
- Version: 1.3.5
3
+ Version: 1.5.0
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,17 +1,17 @@
1
- flashforge/__init__.py,sha256=rWsmx8X1akurtqwcS_cSeZGkzbSXc3Nnd1xeNsnAAj4,5472
2
- flashforge/client.py,sha256=nDtnv3y9XlEQknIkgIUiuOYAiyTEGbQ7tHBdCs2fg1I,21432
3
- flashforge/exceptions.py,sha256=rZwBJHyqqtoI6cgd8iGOvPH8EDcytA3lyU3FoVMJgyU,1732
1
+ flashforge/__init__.py,sha256=dDzpUX4K063NRYBvhj1QkfFxxQpb1aSMMkSEFQTzxhI,5472
2
+ flashforge/client.py,sha256=5DxKJyicq5LX8xfZUtIB0Q-_sh8kIFNzMMV_3Dos5fk,22519
3
+ flashforge/exceptions.py,sha256=LQli1pAH2X8fm3jsRD4caz0Mn-nyBjNpaJh_En7YJw0,1737
4
4
  flashforge/api/__init__.py,sha256=vQz-DkG6LTH39bI4fyiUc0D9jQzemLh45pORLptSOlg,335
5
5
  flashforge/api/constants/__init__.py,sha256=Q0HL2tqSBYPd4Oz49VHLS3qUvRuv__GCvTGecaLrQ-Y,163
6
- flashforge/api/constants/commands.py,sha256=XM2rooBESPDaywlZrfW2ECTaLLFaSKXkJ7FQ-RBmVdY,578
6
+ flashforge/api/constants/commands.py,sha256=9WggRLBgidf4cBBXMllQVAZr64UvHOf7gNHlPEKvo2g,579
7
7
  flashforge/api/constants/endpoints.py,sha256=oZtOFfkU64THDeCMUIVv6_0L-BOUZzgLKAUfjHkUhi8,337
8
8
  flashforge/api/controls/__init__.py,sha256=53s-H25Pjwr0kvPk8MZ2Twy8VHGqGcO6Q6uBphhEOh4,293
9
- flashforge/api/controls/control.py,sha256=OgE7K6NkFLwFN1r_q5g1RPwYC8MCt0f6L5CZxDW44d8,15151
10
- flashforge/api/controls/creator5_palette.py,sha256=zdD3PIXx7hCVj2Fmv1CSFOaqogRUPeBaymgY2DFiMxk,8835
11
- flashforge/api/controls/files.py,sha256=Jk2ySrJdw2QDk1CJlGfY4bTmZJocx4GXztkEqE4rRaI,8830
12
- flashforge/api/controls/info.py,sha256=O6NVGSRwhArUqxCGXHK5WV20-Nn_8rHGUMbZheXXEjs,23973
13
- flashforge/api/controls/job_control.py,sha256=NnACm4LPUz4n8lnRiUl33jilWekS1b6HQTbP--nDzZc,33225
14
- flashforge/api/controls/temp_control.py,sha256=EOO5TkODirjFcnQ7rZrirxAweHBdVin032AWj-Z8VQs,13040
9
+ flashforge/api/controls/control.py,sha256=mK-k3SX53wrq3-aPmMaAbAnqcOaJgvuNSR-sm1aowJM,15099
10
+ flashforge/api/controls/creator5_palette.py,sha256=1I6QfAYlCJcpqPzrySsXfn2EST04QYHZKVxCRnxu4AQ,8795
11
+ flashforge/api/controls/files.py,sha256=tOqPgf9SBIvJBgno1GMAROdoDHF_ZJOmbEac1ikq_HI,8769
12
+ flashforge/api/controls/info.py,sha256=E-VepEeTyquh50ssckpMGIkH9cagNZGUzn_Zb-bPplY,24846
13
+ flashforge/api/controls/job_control.py,sha256=e_8A1-avghNGQ3OBzTbScDnUCUOJobdiulFCyXtyb8w,35294
14
+ flashforge/api/controls/temp_control.py,sha256=_qrKCfjKJsPE4dj6bXZeJRH2FjLKx2T2V3pyG2oLrgI,12991
15
15
  flashforge/api/filament/__init__.py,sha256=isT2dl0hzUS0xMTXMm4Tip0GTG1gCsm8LKWa9xPu4Y8,104
16
16
  flashforge/api/filament/filament.py,sha256=TtcC2G2nD9Sq4cOrCZzFLZ6Q0Ve-zfrfuEF9uQ8M3eE,1214
17
17
  flashforge/api/misc/__init__.py,sha256=1AG7vOMRBZQtHp6QUzfr9alcJPeNv3JpU44NrznxDHs,211
@@ -24,8 +24,8 @@ flashforge/api/network/utils.py,sha256=Q5-Vj_1VN611QV_TGpDzrrZ2X8PqaGT0j00BhSHv5
24
24
  flashforge/discovery/__init__.py,sha256=G0WiP70EhfHdjXR1RNlv6xjhyHOdo-HWyOm9J0ds4SM,828
25
25
  flashforge/discovery/discovery.py,sha256=-hOmVIpn8gu-rPYo8FRBfhKb_tBqwamLfmoPVYaDzLI,27614
26
26
  flashforge/models/__init__.py,sha256=hdcK0E4KeDfv_A8Y2aPOHgHziwTjP1RPb_VP-89dOzQ,1086
27
- flashforge/models/machine_info.py,sha256=o0rpT6DPWojg8VMfYleMFIqEaidDvsY6E0MstaVHOT0,24065
28
- flashforge/models/responses.py,sha256=10-8yFfZtLuDmH1TK2xVYl-Uf-zT2WEJnpb2H5ZK3ZU,10637
27
+ flashforge/models/machine_info.py,sha256=Chcpoeus1pppPKMDNsGIiCGGQ6tATezUT6ML5MjubD0,24407
28
+ flashforge/models/responses.py,sha256=P0Zo8c6QywbS0_kEWq-8HsRwwQkCiG4MfeDcGADpPHg,10965
29
29
  flashforge/tcp/__init__.py,sha256=hpnqoWHeRtTwJPzdsVlwGt1njKbAkrgIHKADkLSIRec,1317
30
30
  flashforge/tcp/a3_client.py,sha256=Aqb6MuKkjnNUVdE2LbZKd7bPlFK8XPIm17C_BG_PuNU,15867
31
31
  flashforge/tcp/a4_client.py,sha256=snmAvTCpSWFjxb8h6V7H4tk5yOoMqKEzKoBY_1JaaPs,11213
@@ -42,8 +42,8 @@ flashforge/tcp/parsers/print_status.py,sha256=C-KzukhK0TR86AP3t3XrMH4dief6pa3I-y
42
42
  flashforge/tcp/parsers/printer_info.py,sha256=CHPs6nJfEByXyG3co7Nb8_ygggxkYuc7A9O9bpQEqXE,5441
43
43
  flashforge/tcp/parsers/temp_info.py,sha256=9wRGUM9cKvDiZD3SIxBx_qVLZbeXoYsoY3-R3vN3H4g,7938
44
44
  flashforge/tcp/parsers/thumbnail_info.py,sha256=1U1S_gcIZ2lDpEA4622ywUew6TVw0BnzjlBAiihQwzk,10478
45
- flashforge_python_api-1.3.5.dist-info/METADATA,sha256=oSmQO3OQPkDynC5evAjLOdK6jpCaD2SmT7DZk6lC5CY,4970
46
- flashforge_python_api-1.3.5.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
47
- flashforge_python_api-1.3.5.dist-info/entry_points.txt,sha256=AkOxlsLvQ7cvMLxn7tlzfKp_DCH2hXhbVceHIXxawpU,66
48
- flashforge_python_api-1.3.5.dist-info/licenses/LICENSE,sha256=-cTA-hrmvlb3pqlQrBZQXUnKayhUjsLJMPb7TD91frM,1067
49
- flashforge_python_api-1.3.5.dist-info/RECORD,,
45
+ flashforge_python_api-1.5.0.dist-info/METADATA,sha256=hpoGseNvotHfDNDJXpItMUbp9Ehr75sj-wgOgO2jfDU,4970
46
+ flashforge_python_api-1.5.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
47
+ flashforge_python_api-1.5.0.dist-info/entry_points.txt,sha256=AkOxlsLvQ7cvMLxn7tlzfKp_DCH2hXhbVceHIXxawpU,66
48
+ flashforge_python_api-1.5.0.dist-info/licenses/LICENSE,sha256=-cTA-hrmvlb3pqlQrBZQXUnKayhUjsLJMPb7TD91frM,1067
49
+ flashforge_python_api-1.5.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.31.0
2
+ Generator: hatchling 1.32.4
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any