flashforge-python-api 1.3.0__py3-none-any.whl → 1.3.2__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
@@ -127,7 +127,7 @@ from .tcp import (
127
127
  )
128
128
 
129
129
  FiveMClient = FlashForgeClient
130
- __version__ = "1.3.0"
130
+ __version__ = "1.3.1"
131
131
  __author__ = "FlashForge Python API Contributors"
132
132
  __email__ = "notghosttypes@gmail.com"
133
133
  __description__ = "Python library for controlling FlashForge 3D printers"
@@ -70,7 +70,7 @@ CREATOR5_PALETTE: tuple[Creator5PaletteColor, ...] = (
70
70
  )
71
71
 
72
72
  # 25^7, the CIEDE2000 chroma weighting constant.
73
- _25_POW_7 = 25.0 ** 7
73
+ _25_POW_7 = 25.0**7
74
74
 
75
75
  # D65 reference white point used by the sRGB -> XYZ transform.
76
76
  _D65_XN = 0.95047
@@ -132,7 +132,7 @@ def _delta_e_2000(c1: tuple[float, float, float], c2: tuple[float, float, float]
132
132
  C1 = math.sqrt(a1 * a1 + b1 * b1)
133
133
  C2 = math.sqrt(a2 * a2 + b2 * b2)
134
134
  Cbar = (C1 + C2) / 2.0
135
- Cbar7 = Cbar ** 7
135
+ Cbar7 = Cbar**7
136
136
  G = 0.5 * (1 - math.sqrt(Cbar7 / (Cbar7 + _25_POW_7)))
137
137
 
138
138
  a1p = (1 + G) * a1
@@ -178,7 +178,7 @@ def _delta_e_2000(c1: tuple[float, float, float], c2: tuple[float, float, float]
178
178
  )
179
179
 
180
180
  dTheta = 30 * math.exp(-(((hbarp - 275) / 25.0) ** 2))
181
- Cbarp7 = Cbarp ** 7
181
+ Cbarp7 = Cbarp**7
182
182
  RC = 2 * math.sqrt(Cbarp7 / (Cbarp7 + _25_POW_7))
183
183
  SL = 1 + (0.015 * (Lbarp - 50) ** 2) / math.sqrt(20 + (Lbarp - 50) ** 2)
184
184
  SC = 1 + 0.045 * Cbarp
@@ -3,6 +3,7 @@ FlashForge Python API - Files Module
3
3
  """
4
4
 
5
5
  import base64
6
+ import logging
6
7
  from typing import TYPE_CHECKING
7
8
 
8
9
  from pydantic import ValidationError
@@ -15,6 +16,8 @@ from ..network.utils import NetworkUtils, json_from_response
15
16
  if TYPE_CHECKING:
16
17
  from ...client import FlashForgeClient
17
18
 
19
+ logger = logging.getLogger(__name__)
20
+
18
21
 
19
22
  class Files:
20
23
  """
@@ -93,7 +96,18 @@ class Files:
93
96
  # Parse the response using GCodeListResponse
94
97
  try:
95
98
  result = GCodeListResponse(**data)
96
- except ValidationError:
99
+ except ValidationError as err:
100
+ # The names-only fallback below silently costs the caller
101
+ # every per-file field, which is indistinguishable from a
102
+ # printer that only reports names. Say so, loudly enough to
103
+ # reach an integration's log, or the next model that changes
104
+ # this payload looks like it reports no metadata at all.
105
+ logger.warning(
106
+ "Could not parse the /gcodeList response; falling back to file "
107
+ "names only, so print time, filament weight, and per-tool "
108
+ "material data are unavailable for every file. %s",
109
+ err,
110
+ )
97
111
  raw_list = data.get("gcodeList", [])
98
112
  if isinstance(raw_list, list):
99
113
  entries: list[FFGcodeFileEntry] = []
@@ -127,9 +141,7 @@ class Files:
127
141
  elif isinstance(first_item, FFGcodeFileEntry):
128
142
  # Already FFGcodeFileEntry objects - need explicit type narrowing
129
143
  return [
130
- item
131
- for item in result.gcode_list
132
- if isinstance(item, FFGcodeFileEntry)
144
+ item for item in result.gcode_list if isinstance(item, FFGcodeFileEntry)
133
145
  ]
134
146
 
135
147
  return []
@@ -89,10 +89,17 @@ class MachineInfoParser:
89
89
  print_progress = getattr(detail, "print_progress", 0) or 0
90
90
  est_length = total_job_filament_meters * print_progress
91
91
  est_weight = (getattr(detail, "estimated_right_weight", 0) or 0) * print_progress
92
+ # Material Station presence, derived rather than read off a single
93
+ # field. `hasMatlStation` is AD5X-only: the Creator 5 series omits it
94
+ # from /detail entirely (verified on a Creator 5 Pro, pid 41, firmware
95
+ # 1.9.4) while reporting a fully populated matlStationInfo with four
96
+ # loaded slots, so an absent flag means "not reported", never "absent
97
+ # hardware". Populated slot data is the reliable signal.
92
98
  has_material_station = (
93
99
  getattr(detail, "has_matl_station", None) is True
94
100
  or (getattr(getattr(detail, "matl_station_info", None), "slot_cnt", 0) or 0) > 0
95
- or len(getattr(getattr(detail, "matl_station_info", None), "slot_infos", []) or []) > 0
101
+ or len(getattr(getattr(detail, "matl_station_info", None), "slot_infos", []) or [])
102
+ > 0
96
103
  )
97
104
  printer_name = getattr(detail, "name", "") or ""
98
105
  pid = getattr(detail, "pid", None)
@@ -236,7 +243,9 @@ class MachineInfoParser:
236
243
  print_speed_adjust=getattr(detail, "print_speed_adjust", 0) or 0,
237
244
  filament_type=getattr(detail, "right_filament_type", "") or "",
238
245
  # Machine state
239
- machine_state=MachineInfoParser._get_machine_state(getattr(detail, "status", "") or ""),
246
+ machine_state=MachineInfoParser._get_machine_state(
247
+ getattr(detail, "status", "") or ""
248
+ ),
240
249
  status=getattr(detail, "status", "") or "",
241
250
  total_print_layers=getattr(detail, "target_print_layer", 0) or 0,
242
251
  tvoc=getattr(detail, "tvoc", 0) or 0,
@@ -249,8 +258,8 @@ class MachineInfoParser:
249
258
  completion_time=completion_time,
250
259
  formatted_run_time=formatted_run_time,
251
260
  formatted_total_run_time=formatted_total_run_time,
252
- # AD5X Material Station
253
- has_matl_station=getattr(detail, "has_matl_station", None),
261
+ # Material Station (AD5X / Creator 5 series)
262
+ has_matl_station=has_material_station,
254
263
  matl_station_info=getattr(detail, "matl_station_info", None),
255
264
  indep_matl_info=getattr(detail, "indep_matl_info", None),
256
265
  )
@@ -698,9 +698,7 @@ class JobControl:
698
698
  if not material_mappings or len(material_mappings) == 0:
699
699
  if allow_empty:
700
700
  return True
701
- print(
702
- f"{label} error: materialMappings array cannot be empty for multi-color jobs"
703
- )
701
+ print(f"{label} error: materialMappings array cannot be empty for multi-color jobs")
704
702
  return False
705
703
 
706
704
  if len(material_mappings) > 4:
@@ -784,7 +782,9 @@ class JobControl:
784
782
  return base64.b64encode(json_string.encode("utf-8")).decode("utf-8")
785
783
  except Exception as error:
786
784
  print("Failed to encode material mappings to base64:", error)
787
- raise Exception("Failed to encode material mappings for upload") from error # noqa: B904
785
+ raise Exception(
786
+ "Failed to encode material mappings for upload"
787
+ ) from error # noqa: B904
788
788
 
789
789
  def _validate_material_mappings(self, material_mappings: list[AD5XMaterialMapping]) -> bool:
790
790
  """
@@ -270,9 +270,7 @@ class TempControl:
270
270
  True if the command is acknowledged, False otherwise.
271
271
  """
272
272
  if not self.client.is_creator5 and not self.client.is_creator5_pro:
273
- print(
274
- "cancel_chamber_temp() error, chamber heater only available on Creator 5 series."
275
- )
273
+ print("cancel_chamber_temp() error, chamber heater only available on Creator 5 series.")
276
274
  return False
277
275
  return await self._send_http_temp_command(chamber=TEMP_OFF)
278
276
 
flashforge/client.py CHANGED
@@ -6,9 +6,11 @@ communication layers for controlling FlashForge 3D printers.
6
6
  """
7
7
 
8
8
  import asyncio
9
+ import logging
9
10
  from dataclasses import dataclass
10
11
 
11
12
  import aiohttp
13
+ from pydantic import ValidationError
12
14
 
13
15
  from .api.constants.endpoints import CAMERA_STREAM_PORT, Endpoints
14
16
  from .api.controls import Control, Files, Info, JobControl, TempControl
@@ -19,6 +21,8 @@ from .tcp import FlashForgeClient as TcpClient
19
21
  from .tcp import FlashForgeTcpClientOptions, PrinterInfo
20
22
  from .tcp.parsers.temp_info import TempInfo
21
23
 
24
+ logger = logging.getLogger(__name__)
25
+
22
26
 
23
27
  @dataclass(slots=True)
24
28
  class FiveMClientConnectionOptions:
@@ -476,8 +480,21 @@ class FlashForgeClient:
476
480
  self._apply_feature_overrides()
477
481
  return True
478
482
 
483
+ except ValidationError as error:
484
+ # Distinct from a rejected check code, and it must not read as one:
485
+ # the printer answered 200 with code 0, we simply could not parse
486
+ # what it said. Callers treat a False return as bad credentials
487
+ # (ff-5mp-hass surfaces it as "check code incorrect"), so the log
488
+ # line is the only place the real cause can surface.
489
+ logger.warning(
490
+ "Could not parse the /product response; reporting the printer as "
491
+ "unavailable rather than the credentials as rejected. This usually "
492
+ "means the firmware added a field this library has not seen. %s",
493
+ error,
494
+ )
495
+ return False
479
496
  except Exception as error:
480
- print(f"Error in send_product_command: {error}")
497
+ logger.warning("Error in send_product_command: %s", error)
481
498
  return False
482
499
  finally:
483
500
  self._http_client_busy = False
@@ -303,7 +303,9 @@ class PrinterDiscovery:
303
303
  try:
304
304
  transport, _ = await self._create_endpoint(message_queue, error_queue)
305
305
  self._send_discovery_packets(transport, config)
306
- discovered_printers = await self._receive_responses(message_queue, error_queue, config)
306
+ discovered_printers = await self._receive_responses(
307
+ message_queue, error_queue, config
308
+ )
307
309
 
308
310
  for printer in discovered_printers:
309
311
  key = f"{printer.ip_address}:{printer.command_port}"
@@ -454,7 +456,9 @@ class PrinterDiscovery:
454
456
  status_code = int.from_bytes(buffer[0x8A:0x8C], byteorder="big")
455
457
  product_type = int.from_bytes(buffer[0x8C:0x8E], byteorder="big")
456
458
  event_port = int.from_bytes(buffer[0x8E:0x90], byteorder="big")
457
- serial_number = buffer[0x92 : 0x92 + 128].decode("utf-8", errors="ignore").split("\x00", 1)[0]
459
+ serial_number = (
460
+ buffer[0x92 : 0x92 + 128].decode("utf-8", errors="ignore").split("\x00", 1)[0]
461
+ )
458
462
  model = self.detect_modern_model(name, product_type, product_id)
459
463
 
460
464
  return DiscoveredPrinter(
@@ -586,9 +590,7 @@ class PrinterDiscovery:
586
590
  continue
587
591
 
588
592
  mask_int = (0xFFFFFFFF << (32 - prefix)) & 0xFFFFFFFF
589
- netmask = ".".join(
590
- str((mask_int >> (8 * (3 - i))) & 0xFF) for i in range(4)
591
- )
593
+ netmask = ".".join(str((mask_int >> (8 * (3 - i))) & 0xFF) for i in range(4))
592
594
 
593
595
  broadcast = self.calculate_broadcast_address(ip_addr, netmask)
594
596
  if broadcast and broadcast not in broadcast_addresses:
@@ -695,7 +697,9 @@ class FlashForgePrinterDiscovery:
695
697
  return None
696
698
 
697
699
  if LEGACY_PROTOCOL_SIZE <= len(response) < MODERN_PROTOCOL_SIZE and len(response) >= 0x92:
698
- legacy_name = response[0:32].decode("utf-8", errors="ignore").split("\x00", 1)[0].strip()
700
+ legacy_name = (
701
+ response[0:32].decode("utf-8", errors="ignore").split("\x00", 1)[0].strip()
702
+ )
699
703
  legacy_serial = (
700
704
  response[0x92 : 0x92 + 32]
701
705
  .decode("utf-8", errors="ignore")
@@ -29,6 +29,10 @@ class MachineState(Enum):
29
29
  class Temperature(BaseModel):
30
30
  """Represents a pair of current and target temperatures for a component like an extruder or print bed."""
31
31
 
32
+ # `extra="forbid"` deliberately: this model is never parsed from a printer
33
+ # payload, only constructed by MachineInfoParser.from_detail. Forbidding
34
+ # extras there catches a typo'd keyword argument in our own parser, which
35
+ # `extra="allow"` would silently absorb.
32
36
  model_config = ConfigDict(extra="forbid")
33
37
 
34
38
  current: float = Field(
@@ -42,7 +46,12 @@ class Temperature(BaseModel):
42
46
  class SlotInfo(BaseModel):
43
47
  """Information about a single slot in the material station."""
44
48
 
45
- model_config = ConfigDict(extra="forbid", populate_by_name=True)
49
+ # `extra="allow"`: inbound, nested under FFPrinterDetail.matl_station_info.
50
+ # FFPrinterDetail allowing extras is not enough on its own - a new field on
51
+ # a *child* fails validation for the whole /detail response, and
52
+ # `Info.get_detail_response` swallows that and returns None, which the HA
53
+ # integration reports as "could not retrieve printer information".
54
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
46
55
 
47
56
  has_filament: bool = Field(
48
57
  alias="hasFilament", description="Indicates if filament is present in this slot"
@@ -69,7 +78,8 @@ class SlotInfo(BaseModel):
69
78
  class MatlStationInfo(BaseModel):
70
79
  """Detailed information about the material station."""
71
80
 
72
- model_config = ConfigDict(extra="forbid", populate_by_name=True)
81
+ # `extra="allow"`: inbound, see SlotInfo.
82
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
73
83
 
74
84
  current_load_slot: int = Field(
75
85
  alias="currentLoadSlot", ge=0, le=4, description="Currently loading slot ID (0 if none)"
@@ -98,7 +108,8 @@ class MatlStationInfo(BaseModel):
98
108
  class IndepMatlInfo(BaseModel):
99
109
  """Information related to independent material loading, often used when a single extruder printer has a material station."""
100
110
 
101
- model_config = ConfigDict(extra="forbid", populate_by_name=True)
111
+ # `extra="allow"`: inbound, see SlotInfo.
112
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
102
113
 
103
114
  material_color: str = Field(alias="materialColor", description="Color of the material")
104
115
  material_name: str = Field(
@@ -123,7 +134,10 @@ class IndepMatlInfo(BaseModel):
123
134
  class FFGcodeToolData(BaseModel):
124
135
  """Represents data for a single tool/material used in a G-code file, typically part of a multi-material print."""
125
136
 
126
- model_config = ConfigDict(extra="forbid", populate_by_name=True)
137
+ # `extra="allow"`: inbound, nested under FFGcodeFileEntry - which already
138
+ # allows extras, but a child that forbids them reintroduces the same
139
+ # names-only fallback it was flipped to avoid.
140
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
127
141
 
128
142
  filament_weight: float = Field(
129
143
  alias="filamentWeight",
@@ -158,7 +172,11 @@ class FFGcodeToolData(BaseModel):
158
172
  class FFGcodeFileEntry(BaseModel):
159
173
  """Represents a single G-code file entry as returned by the /gcodeList endpoint, especially for printers like AD5X that provide detailed material info."""
160
174
 
161
- model_config = ConfigDict(extra="forbid", populate_by_name=True)
175
+ # `extra="allow"`, matching FFPrinterDetail: a firmware update that adds one
176
+ # field must not fail validation, because Files.get_recent_file_list falls
177
+ # back to a names-only list when it does - silently dropping print time,
178
+ # filament weight, and the per-tool material data for EVERY file.
179
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
162
180
 
163
181
  gcode_file_name: str = Field(
164
182
  alias="gcodeFileName", description="The name of the G-code file (e.g., 'FISH_PLA.3mf')"
@@ -231,6 +249,10 @@ class FFPrinterDetail(BaseModel):
231
249
  fill_amount: float | None = Field(default=None, ge=0, le=100, alias="fillAmount")
232
250
  firmware_version: str | None = Field(default=None, alias="firmwareVersion")
233
251
  flash_register_code: str | None = Field(default=None, alias="flashRegisterCode")
252
+ # AD5X-only, and firmware omits what does not apply: the Creator 5 series
253
+ # leaves this out of /detail even with four loaded slots, so None means "not
254
+ # reported", NOT "no station". Never gate a feature on it - read the derived
255
+ # FFMachineInfo.has_matl_station, or check matl_station_info yourself.
234
256
  has_matl_station: bool | None = Field(default=None, alias="hasMatlStation")
235
257
  matl_station_info: MatlStationInfo | None = Field(default=None, alias="matlStationInfo")
236
258
  indep_matl_info: IndepMatlInfo | None = Field(default=None, alias="indepMatlInfo")
@@ -289,6 +311,10 @@ class FFMachineInfo(BaseModel):
289
311
  This interface is populated by transforming data from FFPrinterDetail.
290
312
  """
291
313
 
314
+ # `extra="forbid"` deliberately: see Temperature. Built only at
315
+ # `MachineInfoParser.from_detail`, from ~50 keyword arguments, never from
316
+ # raw printer JSON - so forbidding extras is a typo check on our own code,
317
+ # not a firmware-compatibility risk.
292
318
  model_config = ConfigDict(extra="forbid")
293
319
 
294
320
  # Auto-shutdown settings
@@ -395,8 +421,15 @@ class FFMachineInfo(BaseModel):
395
421
  formatted_run_time: str = "00:00"
396
422
  formatted_total_run_time: str = "0h:0m"
397
423
 
398
- # AD5X Material Station
399
- has_matl_station: bool | None = None
424
+ # Material Station (AD5X / Creator 5 series)
425
+ #
426
+ # A capability, not a passthrough: MachineInfoParser DERIVES this from the
427
+ # station data rather than copying the raw `hasMatlStation` field, which the
428
+ # Creator 5 series never reports even with a station attached. It is a plain
429
+ # bool on purpose - there is no "unknown" state to represent, and offering
430
+ # one is what let an unreported flag read as absent hardware. For the
431
+ # untouched firmware value, read FFPrinterDetail.has_matl_station.
432
+ has_matl_station: bool = False
400
433
  matl_station_info: MatlStationInfo | None = None
401
434
  indep_matl_info: IndepMatlInfo | None = None
402
435
 
@@ -10,7 +10,12 @@ from .machine_info import FFGcodeFileEntry, FFPrinterDetail
10
10
  class GenericResponse(BaseModel):
11
11
  """Represents a generic response from the printer's API."""
12
12
 
13
- model_config = ConfigDict(extra="forbid")
13
+ # `extra="allow"` on every inbound model: this is the envelope each printer
14
+ # response inherits, so a firmware update that adds one top-level key must
15
+ # not fail validation here. Callers cannot tell a parse failure from a real
16
+ # error - `send_product_command` returns a bare False either way, which the
17
+ # HA integration reports to the user as "check code incorrect".
18
+ model_config = ConfigDict(extra="allow")
14
19
 
15
20
  code: int
16
21
  message: str = ""
@@ -19,8 +24,6 @@ class GenericResponse(BaseModel):
19
24
  class DetailResponse(GenericResponse):
20
25
  """Represents the structure of the response from the printer's detail endpoint."""
21
26
 
22
- model_config = ConfigDict(extra="forbid")
23
-
24
27
  detail: FFPrinterDetail
25
28
 
26
29
 
@@ -36,7 +39,10 @@ class Product(BaseModel):
36
39
  Field names match the actual camelCase format returned by the printer.
37
40
  """
38
41
 
39
- model_config = ConfigDict(extra="forbid")
42
+ # `extra="allow"`: an unrecognized control-state flag must not fail
43
+ # validation. `send_product_command` has no fallback - it returns False,
44
+ # which is indistinguishable from the printer rejecting the credentials.
45
+ model_config = ConfigDict(extra="allow")
40
46
 
41
47
  chamberTempCtrlState: int # noqa: N815 - field must match API camelCase response format
42
48
  externalFanCtrlState: int # noqa: N815 - field must match API camelCase response format
@@ -55,8 +61,6 @@ class ProductResponse(GenericResponse):
55
61
  and a nested `product` object containing specific control states.
56
62
  """
57
63
 
58
- model_config = ConfigDict(extra="forbid")
59
-
60
64
  product: Product
61
65
 
62
66
 
@@ -96,6 +100,10 @@ class FilamentArgs(BaseModel):
96
100
  class AD5XMaterialMapping(BaseModel):
97
101
  """Represents a material mapping for AD5X multi-color printing. Maps a tool (extruder) to a specific material station slot."""
98
102
 
103
+ # `extra="forbid"` deliberately, as on every outbound param model below:
104
+ # these are request bodies this library constructs, so no firmware update
105
+ # can break them, and forbidding extras turns a caller's typo'd keyword
106
+ # into an error instead of a field the printer silently ignores.
99
107
  model_config = ConfigDict(extra="forbid", populate_by_name=True)
100
108
 
101
109
  tool_id: int = Field(ge=0, le=3, description="Tool ID (0-based: 0, 1, 2, 3)")
@@ -181,9 +189,7 @@ class Creator5JobParams(BaseModel):
181
189
  leveling_before_print: bool = Field(
182
190
  description="Whether to perform bed leveling before printing"
183
191
  )
184
- flow_calibration: bool = Field(
185
- default=False, description="Whether to enable flow calibration"
186
- )
192
+ flow_calibration: bool = Field(default=False, description="Whether to enable flow calibration")
187
193
  time_lapse_video: bool = Field(
188
194
  default=False, description="Whether to enable time lapse video recording"
189
195
  )
@@ -209,15 +215,11 @@ class Creator5UploadParams(BaseModel):
209
215
  leveling_before_print: bool = Field(
210
216
  description="Whether to perform bed leveling before printing"
211
217
  )
212
- flow_calibration: bool = Field(
213
- default=False, description="Whether to enable flow calibration"
214
- )
218
+ flow_calibration: bool = Field(default=False, description="Whether to enable flow calibration")
215
219
  time_lapse_video: bool = Field(
216
220
  default=False, description="Whether to enable time lapse video recording"
217
221
  )
218
- use_matl_station: bool = Field(
219
- description="Whether this is a multi-tool material-station job"
220
- )
222
+ use_matl_station: bool = Field(description="Whether this is a multi-tool material-station job")
221
223
  gcode_tool_cnt: int = Field(
222
224
  ge=1, le=4, description="Number of tools in the G-code (1-4 for the C5)"
223
225
  )
@@ -226,7 +228,9 @@ class Creator5UploadParams(BaseModel):
226
228
  class GCodeListResponse(GenericResponse):
227
229
  """Represents the response structure for a G-code file list request."""
228
230
 
229
- model_config = ConfigDict(extra="forbid", populate_by_name=True)
231
+ # `extra="allow"`: see FFGcodeFileEntry. An unknown field at either level
232
+ # costs the caller all per-file metadata, so neither level may forbid one.
233
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
230
234
 
231
235
  gcode_list: list[str] | list[FFGcodeFileEntry] | None = Field(default=None, alias="gcodeList")
232
236
  gcode_list_detail: list[FFGcodeFileEntry] | None = Field(default=None, alias="gcodeListDetail")
@@ -235,7 +239,8 @@ class GCodeListResponse(GenericResponse):
235
239
  class ThumbnailResponse(GenericResponse):
236
240
  """Represents the response structure for a G-code thumbnail request."""
237
241
 
238
- model_config = ConfigDict(extra="forbid", populate_by_name=True)
242
+ # `extra="allow"`: inbound, see GenericResponse.
243
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
239
244
 
240
245
  image_data: str = Field(
241
246
  alias="imageData", description="The thumbnail image data encoded as a base64 string"
@@ -84,7 +84,11 @@ class FlashForgeA3Client(FlashForgeTcpClient):
84
84
  bare_cmd = self._strip_protocol_prefix(cmd)
85
85
  if bare_cmd.startswith("M661"):
86
86
  return 500
87
- if bare_cmd.startswith("M115") or bare_cmd.startswith("M119") or bare_cmd.startswith("M650"):
87
+ if (
88
+ bare_cmd.startswith("M115")
89
+ or bare_cmd.startswith("M119")
90
+ or bare_cmd.startswith("M650")
91
+ ):
88
92
  return 250
89
93
  return 200
90
94
 
@@ -102,7 +106,7 @@ class FlashForgeA3Client(FlashForgeTcpClient):
102
106
  if "Error: File not exists" in text_prefix:
103
107
  return True
104
108
 
105
- magic_offset = buffer.find(b"\xA2\xA2\x2A\x2A")
109
+ magic_offset = buffer.find(b"\xa2\xa2\x2a\x2a")
106
110
  if magic_offset == -1 or len(buffer) < magic_offset + 8:
107
111
  return False
108
112
 
@@ -163,7 +167,9 @@ class FlashForgeA3Client(FlashForgeTcpClient):
163
167
  elif line.startswith("Mac Address:"):
164
168
  info.mac_address = line.replace("Mac Address:", "", 1).strip()
165
169
  else:
166
- volume_match = re.search(r"X:\s*(\d+)\s+Y:\s*(\d+)\s+Z:\s*(\d+)", line, re.IGNORECASE)
170
+ volume_match = re.search(
171
+ r"X:\s*(\d+)\s+Y:\s*(\d+)\s+Z:\s*(\d+)", line, re.IGNORECASE
172
+ )
167
173
  if volume_match:
168
174
  info.build_volume = A3BuildVolume(
169
175
  x=int(volume_match.group(1)),
@@ -331,7 +337,11 @@ class FlashForgeA3Client(FlashForgeTcpClient):
331
337
 
332
338
  files: list[A3FileEntry] = []
333
339
  count_index = next(
334
- (index for index, line in enumerate(lines) if re.search(r"info_list\.size:\s*\d+", line)),
340
+ (
341
+ index
342
+ for index, line in enumerate(lines)
343
+ if re.search(r"info_list\.size:\s*\d+", line)
344
+ ),
335
345
  -1,
336
346
  )
337
347
  if count_index == -1:
@@ -354,11 +364,13 @@ class FlashForgeA3Client(FlashForgeTcpClient):
354
364
  if "Error: File not exists" in error_text:
355
365
  return None
356
366
 
357
- magic_offset = buffer.find(b"\xA2\xA2\x2A\x2A")
367
+ magic_offset = buffer.find(b"\xa2\xa2\x2a\x2a")
358
368
  if magic_offset == -1 or len(buffer) < magic_offset + 8:
359
369
  return None
360
370
 
361
- payload_length = int.from_bytes(buffer[magic_offset + 4 : magic_offset + 8], byteorder="big")
371
+ payload_length = int.from_bytes(
372
+ buffer[magic_offset + 4 : magic_offset + 8], byteorder="big"
373
+ )
362
374
  payload_end = magic_offset + 8 + payload_length
363
375
  if len(buffer) < payload_end:
364
376
  return None
@@ -378,7 +390,11 @@ class FlashForgeA3Client(FlashForgeTcpClient):
378
390
  return normalized.rstrip()
379
391
 
380
392
  def _get_normalized_lines(self, response: str) -> list[str]:
381
- return [line.strip() for line in self._normalize_a3_text_response(response).split("\n") if line.strip()]
393
+ return [
394
+ line.strip()
395
+ for line in self._normalize_a3_text_response(response).split("\n")
396
+ if line.strip()
397
+ ]
382
398
 
383
399
  def _map_controller_command(self, cmd: str) -> str:
384
400
  if cmd == "~M146 r255 g255 b255 F0":
@@ -399,7 +415,10 @@ class FlashForgeA3Client(FlashForgeTcpClient):
399
415
  if bare_cmd.startswith("M23"):
400
416
  return "File opened" in normalized or "ok" in normalized
401
417
  if bare_cmd.startswith("M112"):
402
- return bool(re.search(r"Emergency Stop", normalized, re.IGNORECASE)) or "Received." in normalized
418
+ return (
419
+ bool(re.search(r"Emergency Stop", normalized, re.IGNORECASE))
420
+ or "Received." in normalized
421
+ )
403
422
  if "ok" in normalized or "Received." in normalized:
404
423
  return True
405
424
  return normalized.startswith(bare_cmd)
@@ -274,7 +274,11 @@ class FlashForgeA4Client(FlashForgeTcpClient):
274
274
  return normalized
275
275
 
276
276
  def _get_normalized_lines(self, response: str) -> list[str]:
277
- return [line.strip() for line in self._normalize_a4_text_response(response).split("\n") if line.strip()]
277
+ return [
278
+ line.strip()
279
+ for line in self._normalize_a4_text_response(response).split("\n")
280
+ if line.strip()
281
+ ]
278
282
 
279
283
  def _detect_variant(self, machine_type: str) -> A4PrinterVariant:
280
284
  normalized_type = machine_type.upper()
@@ -118,7 +118,9 @@ class EndstopStatus:
118
118
  return None
119
119
 
120
120
  try:
121
- lines = [line.strip() for line in replay.replace("\r", "\n").split("\n") if line.strip()]
121
+ lines = [
122
+ line.strip() for line in replay.replace("\r", "\n").split("\n") if line.strip()
123
+ ]
122
124
  if not lines:
123
125
  return None
124
126
 
@@ -47,7 +47,9 @@ class LocationInfo:
47
47
  The populated LocationInfo instance, or None if parsing fails
48
48
  """
49
49
  try:
50
- lines = [line.strip() for line in replay.replace("\r", "\n").split("\n") if line.strip()]
50
+ lines = [
51
+ line.strip() for line in replay.replace("\r", "\n").split("\n") if line.strip()
52
+ ]
51
53
  coordinate_line = next(
52
54
  (line for line in lines if "X:" in line and "Y:" in line and "Z:" in line),
53
55
  "",
@@ -48,7 +48,9 @@ class PrintStatus:
48
48
  self.sd_total = ""
49
49
  self.layer_current = ""
50
50
  self.layer_total = ""
51
- lines = [line.strip() for line in replay.replace("\r", "\n").split("\n") if line.strip()]
51
+ lines = [
52
+ line.strip() for line in replay.replace("\r", "\n").split("\n") if line.strip()
53
+ ]
52
54
 
53
55
  for line in lines:
54
56
  if "SD printing byte " in line:
@@ -70,7 +70,9 @@ class PrinterInfo:
70
70
  return None
71
71
 
72
72
  try:
73
- lines = [line.strip() for line in replay.replace("\r", "\n").split("\n") if line.strip()]
73
+ lines = [
74
+ line.strip() for line in replay.replace("\r", "\n").split("\n") if line.strip()
75
+ ]
74
76
 
75
77
  for line in lines:
76
78
  if line.startswith("Machine Type:"):
@@ -127,7 +127,9 @@ class TempInfo:
127
127
  return None
128
128
 
129
129
  try:
130
- lines = [line.strip() for line in replay.replace("\r", "\n").split("\n") if line.strip()]
130
+ lines = [
131
+ line.strip() for line in replay.replace("\r", "\n").split("\n") if line.strip()
132
+ ]
131
133
  if not lines:
132
134
  logger.error("TempInfo replay has invalid data: %s", replay)
133
135
  return None
@@ -50,7 +50,7 @@ class ThumbnailInfo:
50
50
  return None
51
51
 
52
52
  binary_buffer = replay.encode("latin1")
53
- magic_marker = b"\xA2\xA2\x2A\x2A"
53
+ magic_marker = b"\xa2\xa2\x2a\x2a"
54
54
  magic_offset = binary_buffer.find(magic_marker)
55
55
  if magic_offset >= 0 and len(binary_buffer) >= magic_offset + 8:
56
56
  payload_length = int.from_bytes(
@@ -150,10 +150,9 @@ class FlashForgeTcpClient:
150
150
  logger.error("Upload failed: remote file name resolved to an empty value.")
151
151
  return False
152
152
 
153
- start_command = (
154
- GCodes.CMD_PREP_FILE_UPLOAD.replace("%%size%%", str(file_path.stat().st_size))
155
- .replace("%%filename%%", normalized_file_name)
156
- )
153
+ start_command = GCodes.CMD_PREP_FILE_UPLOAD.replace(
154
+ "%%size%%", str(file_path.stat().st_size)
155
+ ).replace("%%filename%%", normalized_file_name)
157
156
 
158
157
  async with self._socket_lock:
159
158
  try:
@@ -184,7 +183,9 @@ class FlashForgeTcpClient:
184
183
  GCodes.CMD_COMPLETE_FILE_UPLOAD
185
184
  )
186
185
  if not finish_response:
187
- logger.error("Upload failed: printer did not respond to M29 upload finalization.")
186
+ logger.error(
187
+ "Upload failed: printer did not respond to M29 upload finalization."
188
+ )
188
189
  return False
189
190
 
190
191
  return self._is_successful_upload_boundary_response(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flashforge-python-api
3
- Version: 1.3.0
3
+ Version: 1.3.2
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
@@ -57,6 +57,8 @@ Python library for controlling FlashForge 3D printers with async support for the
57
57
  | Adventurer 5M | Full |
58
58
  | Adventurer 5M Pro | Full |
59
59
  | AD5X | Full |
60
+ | Creator 5 | Full (HTTP-only) |
61
+ | Creator 5 Pro | Full (HTTP-only) |
60
62
  | Adventurer 3 / 4 | Dedicated TCP clients |
61
63
 
62
64
  ## Installation
@@ -138,9 +140,9 @@ asyncio.run(main())
138
140
  - printer status and machine information
139
141
  - job control
140
142
  - file listing, uploads, and thumbnails
141
- - temperature and motion control
143
+ - temperature and motion control, including per-nozzle and chamber temperature control (Creator 5)
142
144
  - LED, camera, and filtration control where supported
143
- - AD5X-specific job and material-station support
145
+ - AD5X and Creator 5 / Creator 5 Pro material-station, slot configuration, and material mapping
144
146
 
145
147
  ## Documentation
146
148
 
@@ -1,16 +1,16 @@
1
- flashforge/__init__.py,sha256=T-Rf3whhvd9klW5n-Jwbo422twqFEVXH3pyShkw5JgU,5336
2
- flashforge/client.py,sha256=y2LjwfBhlS8fwIFEITybvq1Wi_9JdIiJ4pEzw0OdqEo,20463
1
+ flashforge/__init__.py,sha256=0PGa08cJ2RjAlIyJqX4MW3w1I-FUIg8ftaJ6I-A5cc0,5336
2
+ flashforge/client.py,sha256=CwgRq58JZNuFSNdp3x49kUs6tyxD7KHJGifjPH4_oEw,21321
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
8
  flashforge/api/controls/control.py,sha256=N1DGpuyvuPWzyMd4_CvbyaQQwoacPIRE8yBaFyy0d9g,14715
9
- flashforge/api/controls/creator5_palette.py,sha256=hB0OF4sOrf7bVy3n4cPocJkfr8uM1MHRRg4ccCd6xfU,8780
10
- flashforge/api/controls/files.py,sha256=BhSQxXMt8MX2acyWtRzyNXfPYHsy6IkCwH04ksMCIp4,7114
11
- flashforge/api/controls/info.py,sha256=fQ5ICxAfIK4VojcSee_TYM8H585oPJWRDAgwv8MH5Mk,17029
12
- flashforge/api/controls/job_control.py,sha256=uLZQgtzto15AOauUgNg-031cSy_m1W28p9H8y3vZi9M,31955
13
- flashforge/api/controls/temp_control.py,sha256=x9KWkkk6dgdOFR1DAWj2fW7tn-PH0ne-xyrB0XidsTo,12865
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
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
@@ -20,28 +20,28 @@ flashforge/api/network/__init__.py,sha256=G5fBoAlq-NQPDkWp579mFIqsj0v7B1Lb2TAzO6
20
20
  flashforge/api/network/fnet_code.py,sha256=BR2niVKKk4ZfXtizUDIMXzDhlCxPHzqWg5AQQIcoj3g,402
21
21
  flashforge/api/network/utils.py,sha256=Q5-Vj_1VN611QV_TGpDzrrZ2X8PqaGT0j00BhSHv5MM,2096
22
22
  flashforge/discovery/__init__.py,sha256=G0WiP70EhfHdjXR1RNlv6xjhyHOdo-HWyOm9J0ds4SM,828
23
- flashforge/discovery/discovery.py,sha256=c_lT8pgegEAp9LV5seljp5TQLjNxEQcE8CXAAOvpWQ4,27566
23
+ flashforge/discovery/discovery.py,sha256=-hOmVIpn8gu-rPYo8FRBfhKb_tBqwamLfmoPVYaDzLI,27614
24
24
  flashforge/models/__init__.py,sha256=hdcK0E4KeDfv_A8Y2aPOHgHziwTjP1RPb_VP-89dOzQ,1086
25
- flashforge/models/machine_info.py,sha256=wqVOTkxhRf2zrt0yr3SGJGr8SRnMXYiqEBZzQBLJHc0,17064
26
- flashforge/models/responses.py,sha256=q5DtcTys0g_QIdn_CeYMykQrzyg5TGo11B7T5OCYj-A,9654
25
+ flashforge/models/machine_info.py,sha256=UD-BQ-VzceSIoFFrpuFPJLpaisdgxBRxVD8SZ20mF4E,19384
26
+ flashforge/models/responses.py,sha256=10-8yFfZtLuDmH1TK2xVYl-Uf-zT2WEJnpb2H5ZK3ZU,10637
27
27
  flashforge/tcp/__init__.py,sha256=hpnqoWHeRtTwJPzdsVlwGt1njKbAkrgIHKADkLSIRec,1317
28
- flashforge/tcp/a3_client.py,sha256=a1jdDcBnTSNrzJFKTuVV13fSLbZmxKEZ94n3cGf3wOA,15603
29
- flashforge/tcp/a4_client.py,sha256=0KH5zfsbWaPRd7xSKb7YA3oZTkCVdsdcTA9LPl3S55Q,11167
28
+ flashforge/tcp/a3_client.py,sha256=Aqb6MuKkjnNUVdE2LbZKd7bPlFK8XPIm17C_BG_PuNU,15867
29
+ flashforge/tcp/a4_client.py,sha256=snmAvTCpSWFjxb8h6V7H4tk5yOoMqKEzKoBY_1JaaPs,11213
30
30
  flashforge/tcp/ff_client.py,sha256=CJXk0Qei2FVSpbXxZhiEQgAEcTJaBab1ouooqyC4OL4,20235
31
- flashforge/tcp/tcp_client.py,sha256=KjymxUXiXgEp9y7mjr5tNO_Y2-bsGEyP-oG5en7rCCs,16996
31
+ flashforge/tcp/tcp_client.py,sha256=Ae5Z5rLJFoDYxP3T6Q_PtVNkUr94QqFEWPKtgFyMIJw,17027
32
32
  flashforge/tcp/gcode/__init__.py,sha256=tFGlqCPFxVJNizXsIG4pMOK7IPFNnc_vIEMciULYkMY,271
33
33
  flashforge/tcp/gcode/a3_gcode_controller.py,sha256=x84_xo_Ll5k6hMAwDWuNUo54znV-BBIfwnmeOiEa9Fs,3688
34
34
  flashforge/tcp/gcode/gcode_controller.py,sha256=6K1VGgX59AcE_Kh_gm13dmxCY_kuzV_d2ooSpheONRI,10075
35
35
  flashforge/tcp/gcode/gcodes.py,sha256=55Ii1JpJNJNmOnBivyF83vz3TZUPDtp01lX72NINfPQ,3658
36
36
  flashforge/tcp/parsers/__init__.py,sha256=kDaJmiH0JcR5i693Fj3pwmINLWN0ipirN3LWGFvPst0,730
37
- flashforge/tcp/parsers/endstop_status.py,sha256=4jTtodJiiAVeeU8Z5daFtETm8ExPGo5P1CQWZ9KYrfg,9815
38
- flashforge/tcp/parsers/location_info.py,sha256=n1lUUmbk4kuuuBUbIA65dlPa4tsVGUJFlwnoeRW0yx8,2865
39
- flashforge/tcp/parsers/print_status.py,sha256=4q9ZlJfO0c7_t1o15tgxbguMxpDjEW9pMyaFU6a0Nfk,6164
40
- flashforge/tcp/parsers/printer_info.py,sha256=QY6LECfcOhVHSIdsvQZwJago0OjXroUwiX0H-uQaH2A,5411
41
- flashforge/tcp/parsers/temp_info.py,sha256=9Waf68tt-4QBcChyUwfMHDwWdIzM4mmDpZAKeAzklgs,7908
42
- flashforge/tcp/parsers/thumbnail_info.py,sha256=ZmxdEbVFOzqR1AfhXXZyjENVI66BdRdE7Q8LQ8an9FY,10201
43
- flashforge_python_api-1.3.0.dist-info/METADATA,sha256=q_OdV2PHGRx3TxqoMPIfQ5mC8th-g28-wMLFFHJWkDs,4787
44
- flashforge_python_api-1.3.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
45
- flashforge_python_api-1.3.0.dist-info/entry_points.txt,sha256=AkOxlsLvQ7cvMLxn7tlzfKp_DCH2hXhbVceHIXxawpU,66
46
- flashforge_python_api-1.3.0.dist-info/licenses/LICENSE,sha256=-cTA-hrmvlb3pqlQrBZQXUnKayhUjsLJMPb7TD91frM,1067
47
- flashforge_python_api-1.3.0.dist-info/RECORD,,
37
+ flashforge/tcp/parsers/endstop_status.py,sha256=4OWwE5cyBFhmTPmp18hU4lRr6YSA7sm7U13pHxI1ZJ0,9845
38
+ 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/printer_info.py,sha256=CHPs6nJfEByXyG3co7Nb8_ygggxkYuc7A9O9bpQEqXE,5441
41
+ 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,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.30.1
2
+ Generator: hatchling 1.31.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any