flashforge-python-api 1.2.3__py3-none-any.whl → 1.3.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.
@@ -1,18 +1,45 @@
1
1
  """
2
2
  FlashForge Python API - Temperature Control Module
3
+
4
+ Sets and cancels extruder/bed/chamber temperatures. The 5M / 5M Pro / AD5X use
5
+ direct TCP G-code/M-code commands; HTTP-only printers (Creator 5 / 5 Pro, no TCP
6
+ channel) use the HTTP ``temperatureCtl_cmd`` instead.
3
7
  """
4
8
 
5
9
  from typing import TYPE_CHECKING
6
10
 
11
+ from ..constants.commands import Commands
12
+
7
13
  if TYPE_CHECKING:
8
14
  from ...client import FlashForgeClient
9
15
  from ...tcp.ff_client import FlashForgeClient as TcpClient
10
16
 
11
17
 
18
+ # Sentinel value for `temperatureCtl_cmd` meaning "leave this heater unchanged"
19
+ # (partial update). Sending a real 0 / -100 would turn the heater off.
20
+ TEMP_NO_CHANGE = -200
21
+ # `temperatureCtl_cmd` value that turns a SCALAR heater (platform / chamber /
22
+ # rightNozzle) off.
23
+ TEMP_OFF = -100
24
+ # Value that turns a tool/nozzle OFF inside the `nozzles` array. Unlike the scalar
25
+ # heater fields (which accept TEMP_OFF = -100), the Creator 5 firmware's
26
+ # per-nozzle parser only treats a literal 0 as "off" — it ignores -100 in the
27
+ # `nozzles` array and the tool keeps heating. (Firmware-confirmed via tester
28
+ # report; this is the v1.6.1 nozzle-off bugfix.)
29
+ NOZZLE_OFF = 0
30
+ # Number of tool/nozzle entries the Creator 5 firmware requires in the
31
+ # `nozzles` array. The firmware ignores the array unless its length is exactly
32
+ # this (confirmed via Ghidra: `size() == 4` check in the temp parser).
33
+ NOZZLE_COUNT = 4
34
+
35
+
12
36
  class TempControl:
13
37
  """
14
38
  Provides methods for controlling the temperatures of various printer components,
15
39
  including extruders and the print bed.
40
+
41
+ Dual-API printers (5M / 5M Pro / AD5X) use TCP G-code commands; HTTP-only
42
+ printers (Creator 5 / 5 Pro) route through the HTTP ``temperatureCtl_cmd``.
16
43
  """
17
44
 
18
45
  def __init__(self, client: "FlashForgeClient"):
@@ -32,61 +59,244 @@ class TempControl:
32
59
  self._tcp_client = self.client.tcp_client
33
60
  return self._tcp_client
34
61
 
62
+ async def _send_http_temp_command(
63
+ self,
64
+ right_nozzle: int | None = None,
65
+ left_nozzle: int | None = None,
66
+ platform: int | None = None,
67
+ chamber: int | None = None,
68
+ nozzles: list[int] | None = None,
69
+ ) -> bool:
70
+ """
71
+ Sends a ``temperatureCtl_cmd`` over HTTP with the given setpoints.
72
+ Unspecified heaters are left unchanged via the :data:`TEMP_NO_CHANGE`
73
+ sentinel.
74
+
75
+ The ``nozzles`` array is Creator 5-only (its 4-nozzle tool changer). The
76
+ confirmed C5 payload ALWAYS carries a 4-entry array, even on a bed- or
77
+ chamber-only command, so always include it on the C5 — defaulting
78
+ unspecified tools to :data:`TEMP_NO_CHANGE` so a bed/chamber adjustment
79
+ leaves the tools untouched.
80
+
81
+ Returns:
82
+ True if the command is acknowledged, False otherwise.
83
+ """
84
+ payload: dict[str, int | list[int]] = {
85
+ "rightNozzle": right_nozzle if right_nozzle is not None else TEMP_NO_CHANGE,
86
+ "leftNozzle": left_nozzle if left_nozzle is not None else TEMP_NO_CHANGE,
87
+ "platform": platform if platform is not None else TEMP_NO_CHANGE,
88
+ "chamber": chamber if chamber is not None else TEMP_NO_CHANGE,
89
+ }
90
+ # The `nozzles` array is Creator 5-only. The confirmed C5 payload always
91
+ # carries a 4-entry array, even on a bed- or chamber-only command, so
92
+ # always include it on the C5 — defaulting unspecified tools to
93
+ # TEMP_NO_CHANGE so a bed/chamber adjustment leaves the tools untouched.
94
+ if nozzles is not None:
95
+ payload["nozzles"] = nozzles
96
+ elif self.client.is_creator5:
97
+ payload["nozzles"] = [TEMP_NO_CHANGE] * NOZZLE_COUNT
98
+ return await self.client.control.send_control_command(Commands.TEMP_CONTROL_CMD, payload)
99
+
100
+ def _build_nozzle_array(self, tool_index: int, value: int) -> list[int] | None:
101
+ """
102
+ Builds a :data:`NOZZLE_COUNT`-length ``nozzles`` array of
103
+ :data:`TEMP_NO_CHANGE` placeholders with a single tool set to ``value``.
104
+
105
+ Returns:
106
+ The array, or None if ``tool_index`` is out of range.
107
+ """
108
+ 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}).")
110
+ return None
111
+ nozzles = [TEMP_NO_CHANGE] * NOZZLE_COUNT
112
+ nozzles[tool_index] = value
113
+ return nozzles
114
+
115
+ async def set_tool_temp(self, tool_index: int, temp: int) -> bool:
116
+ """
117
+ Sets the target temperature for a single tool/nozzle on a Creator 5 series
118
+ tool-changer, leaving the other tools unchanged. Sent as a ``nozzles``
119
+ array (Creator 5 / 5 Pro, HTTP-only).
120
+
121
+ Args:
122
+ tool_index: Zero-based tool index (0-3 for T0-T3).
123
+ temp: Target temperature in Celsius.
124
+
125
+ Returns:
126
+ True if the command is acknowledged, False otherwise.
127
+ """
128
+ nozzles = self._build_nozzle_array(tool_index, temp)
129
+ if nozzles is None:
130
+ return False
131
+ return await self._send_http_temp_command(nozzles=nozzles)
132
+
133
+ async def set_tool_temps(self, temps: list[int]) -> bool:
134
+ """
135
+ Sets the target temperatures for all tools/nozzles on a Creator 5 series
136
+ tool-changer in one command. Use :data:`TEMP_NO_CHANGE` (-200) to leave a
137
+ tool unchanged or :data:`NOZZLE_OFF` (0) to turn one off (the firmware
138
+ ignores the -100 sentinel inside the ``nozzles`` array). Must contain
139
+ exactly :data:`NOZZLE_COUNT` entries.
140
+
141
+ Args:
142
+ temps: Per-tool target temperatures, ordered T0..T3.
143
+
144
+ Returns:
145
+ True if the command is acknowledged, False otherwise.
146
+ """
147
+ if len(temps) != NOZZLE_COUNT:
148
+ print(f"set_tool_temps: expected {NOZZLE_COUNT} temps, got {len(temps)}.")
149
+ return False
150
+ return await self._send_http_temp_command(nozzles=list(temps))
151
+
152
+ async def cancel_tool_temp(self, tool_index: int) -> bool:
153
+ """
154
+ Cancels heating for a single tool/nozzle on a Creator 5 series tool-changer
155
+ (sets its target to 0 via :data:`NOZZLE_OFF`), leaving the other tools
156
+ unchanged.
157
+
158
+ Args:
159
+ tool_index: Zero-based tool index (0-3 for T0-T3).
160
+
161
+ Returns:
162
+ True if the command is acknowledged, False otherwise.
163
+ """
164
+ nozzles = self._build_nozzle_array(tool_index, NOZZLE_OFF)
165
+ if nozzles is None:
166
+ return False
167
+ return await self._send_http_temp_command(nozzles=nozzles)
168
+
35
169
  async def set_extruder_temp(self, temperature: int, wait_for: bool = False) -> bool:
36
170
  """
37
- Sets the target temperature for an extruder.
171
+ Sets the target temperature for the printer's extruder.
172
+
173
+ On HTTP-only printers this routes to ``temperatureCtl_cmd``: the Creator 5
174
+ drives its tools only via the ``nozzles`` array (targeting T0), while a
175
+ forced-http_only AD5X / 5M keeps the ``rightNozzle`` scalar.
38
176
 
39
177
  Args:
40
178
  temperature: The target temperature in Celsius.
41
- wait_for: Whether to wait for the heating operation to complete
179
+ wait_for: Whether to wait for the heating operation to complete (TCP only).
42
180
 
43
181
  Returns:
44
182
  True if the command is successful, False otherwise.
45
183
  """
184
+ if self.client.http_only:
185
+ # Creator 5 tools are driven ONLY via the `nozzles` array — the
186
+ # firmware's doTemperatureControl handler never reads
187
+ # rightNozzle/leftNozzle. Target the primary tool (T0): the active
188
+ # tool isn't reliably known over HTTP, so callers that need a
189
+ # specific tool should use set_tool_temp(index, temp).
190
+ if self.client.is_creator5:
191
+ nozzles = self._build_nozzle_array(0, temperature)
192
+ if nozzles is None:
193
+ return False
194
+ return await self._send_http_temp_command(nozzles=nozzles)
195
+ # Single-tool AD5X / 5M only reachable here if forced into http_only
196
+ # mode; they keep the rightNozzle field (unchanged).
197
+ return await self._send_http_temp_command(right_nozzle=temperature)
46
198
  return await self.tcp_client.set_extruder_temp(temperature, wait_for)
47
199
 
48
200
  async def set_bed_temp(self, temperature: int, wait_for: bool = False) -> bool:
49
201
  """
50
- Sets the target temperature for the print bed.
202
+ Sets the target temperature for the printer's print bed.
51
203
 
52
204
  Args:
53
205
  temperature: The target bed temperature in Celsius.
54
- wait_for: Whether to wait for the heating operation to complete
206
+ wait_for: Whether to wait for the heating operation to complete (TCP only).
55
207
 
56
208
  Returns:
57
209
  True if the command is successful, False otherwise.
58
210
  """
211
+ if self.client.http_only:
212
+ return await self._send_http_temp_command(platform=temperature)
59
213
  return await self.tcp_client.set_bed_temp(temperature, wait_for)
60
214
 
61
215
  async def cancel_extruder_temp(self) -> bool:
62
216
  """
63
- Cancels the heating of an extruder (sets target temperature to 0).
217
+ Cancels any ongoing extruder heating and sets its target temperature to 0.
64
218
 
65
219
  Returns:
66
220
  True if the command is successful, False otherwise.
67
221
  """
222
+ if self.client.http_only:
223
+ # See set_extruder_temp: Creator 5 tools are driven only via
224
+ # `nozzles`. Turn the primary tool (T0) off (target 0, not the -100
225
+ # sentinel the nozzles array ignores) while leaving the other tools
226
+ # unchanged.
227
+ if self.client.is_creator5:
228
+ nozzles = self._build_nozzle_array(0, NOZZLE_OFF)
229
+ if nozzles is None:
230
+ return False
231
+ return await self._send_http_temp_command(nozzles=nozzles)
232
+ # Single-tool AD5X / 5M (forced http_only) keep rightNozzle (unchanged).
233
+ return await self._send_http_temp_command(right_nozzle=TEMP_OFF)
68
234
  return await self.tcp_client.cancel_extruder_temp()
69
235
 
70
236
  async def cancel_bed_temp(self) -> bool:
71
237
  """
72
- Cancels the heating of the print bed (sets target temperature to 0).
238
+ Cancels any ongoing print bed heating and sets its target temperature to 0.
73
239
 
74
240
  Returns:
75
241
  True if the command is successful, False otherwise.
76
242
  """
243
+ if self.client.http_only:
244
+ return await self._send_http_temp_command(platform=TEMP_OFF)
77
245
  return await self.tcp_client.cancel_bed_temp()
78
246
 
247
+ async def set_chamber_temp(self, temp: int) -> bool:
248
+ """
249
+ Sets the target temperature for the printer's heated chamber. Only the
250
+ Creator 5 series has a chamber heater, and those printers are HTTP-only,
251
+ so this always goes over the HTTP ``temperatureCtl_cmd``. The firmware
252
+ caps the chamber at 80°C.
253
+
254
+ Args:
255
+ temp: The target temperature in Celsius.
256
+
257
+ Returns:
258
+ True if the command is acknowledged, False otherwise.
259
+ """
260
+ 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.")
262
+ return False
263
+ return await self._send_http_temp_command(chamber=temp)
264
+
265
+ async def cancel_chamber_temp(self) -> bool:
266
+ """
267
+ Cancels any ongoing chamber heating and sets its target temperature to 0.
268
+
269
+ Returns:
270
+ True if the command is acknowledged, False otherwise.
271
+ """
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
+ )
276
+ return False
277
+ return await self._send_http_temp_command(chamber=TEMP_OFF)
278
+
79
279
  async def wait_for_part_cool(
80
280
  self, target_temp: float = 50.0, timeout_seconds: int = 1800
81
281
  ) -> bool:
82
282
  """
83
283
  Waits for printer components to cool down to a safe temperature.
84
284
 
285
+ Relies on TCP G-code polling and is therefore unavailable on HTTP-only
286
+ printers (Creator 5 / 5 Pro); callers should poll ``info.get()`` instead.
287
+
85
288
  Args:
86
289
  target_temp: The target temperature to wait for (default: 50°C).
87
290
  timeout_seconds: Maximum time to wait in seconds (default: 30 minutes).
88
291
 
89
292
  Returns:
90
- True if components cooled to target temperature, False if timeout or error.
293
+ True if components cooled to target temperature, False if timeout, error,
294
+ or HTTP-only (no TCP polling channel).
91
295
  """
296
+ if self.client.http_only:
297
+ print(
298
+ "wait_for_part_cool() unavailable over HTTP-only connection; "
299
+ "poll info.get() instead."
300
+ )
301
+ return False
92
302
  return await self.tcp_client.wait_for_part_cool(target_temp, timeout_seconds)
@@ -19,7 +19,7 @@ async def json_from_response(response: aiohttp.ClientResponse) -> dict:
19
19
  return await response.json() # type: ignore[no-any-return]
20
20
  except aiohttp.ContentTypeError:
21
21
  text = await response.text()
22
- return json.loads(text)
22
+ return json.loads(text) # type: ignore[no-any-return]
23
23
 
24
24
 
25
25
  class NetworkUtils:
flashforge/client.py CHANGED
@@ -27,6 +27,10 @@ class FiveMClientConnectionOptions:
27
27
  http_port: int | None = None
28
28
  tcp_port: int | None = None
29
29
  led_control_override: bool | None = None
30
+ # Force HTTP-only transport (skip the TCP/8899 handshake). Set this for a
31
+ # Creator 5 / Creator 5 Pro, which exposes no TCP service; otherwise it is
32
+ # auto-detected from the firmware-reported model after `verify_connection`.
33
+ http_only: bool | None = None
30
34
 
31
35
 
32
36
  class FlashForgeClient:
@@ -84,6 +88,8 @@ class FlashForgeClient:
84
88
  self.printer_name: str = ""
85
89
  self.is_pro: bool = False
86
90
  self._is_ad5x: bool = False
91
+ self.is_creator5: bool = False
92
+ self.is_creator5_pro: bool = False
87
93
  self.firmware_version: str = ""
88
94
  self.firmware_ver: str = ""
89
95
  self.mac_address: str = ""
@@ -106,6 +112,15 @@ class FlashForgeClient:
106
112
  )
107
113
  self._apply_feature_overrides()
108
114
 
115
+ # Transport selection. Creator 5 / Creator 5 Pro have no TCP/8899
116
+ # service, so they must run HTTP-only. An explicit override wins;
117
+ # otherwise http_only is auto-set from the detected model in
118
+ # `verify_connection` / `cache_details`.
119
+ self._http_only_override: bool | None = (
120
+ options.http_only if options and options.http_only is not None else None
121
+ )
122
+ self._http_only: bool = bool(self._http_only_override)
123
+
109
124
  @property
110
125
  def is_ad5x(self) -> bool:
111
126
  """
@@ -116,6 +131,46 @@ class FlashForgeClient:
116
131
  """
117
132
  return self._is_ad5x
118
133
 
134
+ @property
135
+ def http_only(self) -> bool:
136
+ """
137
+ Indicates whether the client should avoid the TCP transport.
138
+
139
+ True for Creator 5 / Creator 5 Pro (no TCP/8899 service) or when an
140
+ explicit ``http_only`` override was supplied at construction. When True,
141
+ TCP-only operations are unavailable (see :meth:`can_use_tcp`).
142
+
143
+ Returns:
144
+ True if the client must operate over HTTP only
145
+ """
146
+ return self._http_only
147
+
148
+ def can_use_tcp(self, op: str = "") -> bool:
149
+ """
150
+ Report whether a TCP-backed operation may be attempted.
151
+
152
+ Returns False whenever this client is HTTP-only (e.g. a Creator 5 with
153
+ no TCP/8899 service). TCP-delegating control/temperature methods check
154
+ this and no-op (return False) instead of hanging on a dead socket.
155
+
156
+ Args:
157
+ op: Optional operation name, included in the warning log.
158
+
159
+ Returns:
160
+ True if TCP operations are permitted, False otherwise
161
+ """
162
+ if self._http_only:
163
+ print(f"{op}() unavailable: printer has no TCP control channel (HTTP-only).")
164
+ return False
165
+ return True
166
+
167
+ def _update_http_only_from_model(self) -> None:
168
+ """Recompute ``http_only`` from the detected model unless overridden."""
169
+ if self._http_only_override is not None:
170
+ self._http_only = bool(self._http_only_override)
171
+ else:
172
+ self._http_only = self.is_creator5 or self.is_creator5_pro
173
+
119
174
  async def __aenter__(self) -> "FlashForgeClient":
120
175
  """Async context manager entry."""
121
176
  await self._ensure_http_session()
@@ -209,11 +264,15 @@ class FlashForgeClient:
209
264
  Disposes of the FlashForgeClient instance, stopping keep-alive messages
210
265
  and cleaning up resources.
211
266
  """
212
- # Stop TCP keep-alive and dispose
213
- if hasattr(self.tcp_client, "stop_keep_alive"):
214
- await self.tcp_client.stop_keep_alive(True)
215
- if hasattr(self.tcp_client, "dispose"):
216
- await self.tcp_client.dispose()
267
+ # Stop TCP keep-alive and dispose. An HTTP-only client (e.g. Creator 5,
268
+ # which has no TCP/8899 service) never opened a TCP socket, so skip the
269
+ # cleanup handshake -- otherwise the logout send would attempt (and time
270
+ # out on) a connection that was never established.
271
+ if not self._http_only:
272
+ if hasattr(self.tcp_client, "stop_keep_alive"):
273
+ await self.tcp_client.stop_keep_alive(True)
274
+ if hasattr(self.tcp_client, "dispose"):
275
+ await self.tcp_client.dispose()
217
276
 
218
277
  # Close HTTP session
219
278
  if self._http_session and not self._http_session.closed:
@@ -298,6 +357,10 @@ class FlashForgeClient:
298
357
  self.camera_stream_url = info.camera_stream_url or ""
299
358
  self.lifetime_print_time = info.formatted_total_run_time or ""
300
359
  self._is_ad5x = info.is_ad5x
360
+ self.is_creator5 = info.is_creator5
361
+ self.is_creator5_pro = info.is_creator5_pro
362
+ # Refresh transport selection now that the model flags are cached.
363
+ self._update_http_only_from_model()
301
364
 
302
365
  # Format filament usage
303
366
  filament_value = info.cumulative_filament if info.cumulative_filament is not None else 0.0
@@ -338,15 +401,31 @@ class FlashForgeClient:
338
401
  print("Failed to parse machine info from detail response")
339
402
  return False
340
403
 
341
- # Get TCP printer information to check for Pro model
342
- tcp_info: PrinterInfo | None = await self.tcp_client.get_printer_info()
343
- if tcp_info:
344
- if "Pro" in tcp_info.type_name and not machine_info.is_pro and not machine_info.is_ad5x:
345
- self.is_pro = True
404
+ # Detect http_only from the parsed model BEFORE touching TCP. The
405
+ # Creator 5 / Creator 5 Pro expose no TCP/8899 service, so the M115
406
+ # handshake would hang until the connect timeout. An explicit
407
+ # override always wins.
408
+ if self._http_only_override is not None:
409
+ self._http_only = bool(self._http_only_override)
346
410
  else:
347
- print(
348
- "Warning: Unable to get PrinterInfo from TCP API, some features might not work"
349
- )
411
+ self._http_only = machine_info.is_creator5
412
+
413
+ # Get TCP printer information to check for Pro model
414
+ tcp_info: PrinterInfo | None = None
415
+ if not self._http_only:
416
+ tcp_info = await self.tcp_client.get_printer_info()
417
+ if tcp_info:
418
+ if (
419
+ "Pro" in tcp_info.type_name
420
+ and not machine_info.is_pro
421
+ and not machine_info.is_ad5x
422
+ ):
423
+ self.is_pro = True
424
+ else:
425
+ print(
426
+ "Warning: Unable to get PrinterInfo from TCP API, "
427
+ "some features might not work"
428
+ )
350
429
 
351
430
  # Cache the details
352
431
  return self.cache_details(machine_info)
@@ -67,9 +67,25 @@ class PrinterModel(StrEnum):
67
67
  ADVENTURER_5M_PRO = "Adventurer5MPro"
68
68
  ADVENTURER_4 = "Adventurer4"
69
69
  ADVENTURER_3 = "Adventurer3"
70
+ CREATOR_5 = "Creator5"
71
+ CREATOR_5_PRO = "Creator5Pro"
70
72
  UNKNOWN = "Unknown"
71
73
 
72
74
 
75
+ # Canonical USB Product IDs (discovery packet offset 0x88) for modern printers.
76
+ # Same value space as the firmware /detail `pid` field and the FlashForge
77
+ # update-checker keys, so it is the authoritative model discriminator -- unlike
78
+ # `product_type` (0x5A02), which only identifies the 5M *family* and cannot tell
79
+ # 5M / 5M Pro / AD5X / Creator 5 apart.
80
+ MODERN_PRODUCT_IDS = {
81
+ 0x0023: PrinterModel.ADVENTURER_5M,
82
+ 0x0024: PrinterModel.ADVENTURER_5M_PRO,
83
+ 0x0026: PrinterModel.AD5X,
84
+ 0x0028: PrinterModel.CREATOR_5,
85
+ 0x0029: PrinterModel.CREATOR_5_PRO,
86
+ }
87
+
88
+
73
89
  class DiscoveryProtocol(StrEnum):
74
90
  """Discovery response wire formats."""
75
91
 
@@ -439,7 +455,7 @@ class PrinterDiscovery:
439
455
  product_type = int.from_bytes(buffer[0x8C:0x8E], byteorder="big")
440
456
  event_port = int.from_bytes(buffer[0x8E:0x90], byteorder="big")
441
457
  serial_number = buffer[0x92 : 0x92 + 128].decode("utf-8", errors="ignore").split("\x00", 1)[0]
442
- model = self.detect_modern_model(name, product_type)
458
+ model = self.detect_modern_model(name, product_type, product_id)
443
459
 
444
460
  return DiscoveredPrinter(
445
461
  model=model,
@@ -480,18 +496,39 @@ class PrinterDiscovery:
480
496
  status=self.map_status_code(status_code),
481
497
  )
482
498
 
483
- def detect_modern_model(self, name: str, product_type: int) -> PrinterModel:
484
- """Detect a modern printer model from discovery metadata."""
485
- upper_name = name.upper()
499
+ def detect_modern_model(
500
+ self,
501
+ name: str,
502
+ product_type: int,
503
+ product_id: int | None = None,
504
+ ) -> PrinterModel:
505
+ """Detect a modern printer model from discovery metadata.
506
+
507
+ Resolution order:
508
+ 1. USB product ID (offset 0x88) -- authoritative, user-immutable, and
509
+ the only signal that distinguishes 5M / 5M Pro / AD5X / Creator 5.
510
+ 2. product_type 0x5A02 (5M *family*) + name -- fallback for firmware
511
+ reporting an unknown/zero product ID.
512
+ 3. Name heuristics -- last resort.
513
+ """
514
+ # Product ID is authoritative (firmware-set, not user-mutable).
515
+ if product_id is not None and product_id in MODERN_PRODUCT_IDS:
516
+ return MODERN_PRODUCT_IDS[product_id]
486
517
 
487
- if upper_name == "AD5X":
488
- return PrinterModel.AD5X
518
+ upper_name = name.upper()
489
519
 
520
+ # 0x5A02 identifies the 5M family but not the specific model, so we still
521
+ # need the name to separate base from Pro.
490
522
  if product_type == 0x5A02:
523
+ if upper_name == "AD5X":
524
+ return PrinterModel.AD5X
491
525
  if "PRO" in upper_name:
492
526
  return PrinterModel.ADVENTURER_5M_PRO
493
527
  return PrinterModel.ADVENTURER_5M
494
528
 
529
+ if upper_name == "AD5X":
530
+ return PrinterModel.AD5X
531
+
495
532
  if "ADVENTURER 5M" in upper_name or "AD5M" in upper_name:
496
533
  if "PRO" in upper_name:
497
534
  return PrinterModel.ADVENTURER_5M_PRO
@@ -18,6 +18,8 @@ from .responses import (
18
18
  AD5XMaterialMapping,
19
19
  AD5XSingleColorJobParams,
20
20
  AD5XUploadParams,
21
+ Creator5JobParams,
22
+ Creator5UploadParams,
21
23
  DetailResponse,
22
24
  FilamentArgs,
23
25
  GCodeListResponse,
@@ -46,6 +48,8 @@ __all__ = [
46
48
  "AD5XLocalJobParams",
47
49
  "AD5XSingleColorJobParams",
48
50
  "AD5XUploadParams",
51
+ "Creator5JobParams",
52
+ "Creator5UploadParams",
49
53
  "GCodeListResponse",
50
54
  "ThumbnailResponse",
51
55
  ]
@@ -250,6 +250,17 @@ class FFPrinterDetail(BaseModel):
250
250
  nozzle_cnt: int | None = Field(default=None, ge=1, le=4, alias="nozzleCnt")
251
251
  nozzle_model: str | None = Field(default=None, alias="nozzleModel")
252
252
  nozzle_style: int | None = Field(default=None, ge=0, alias="nozzleStyle")
253
+ # --- Creator 5 series raw fields ---
254
+ # Immutable factory model name (e.g. "Creator 5 Pro"); unlike `name` this is
255
+ # not user-editable. May be absent on older firmware.
256
+ model: str | None = Field(default=None, alias="model")
257
+ # Per-tool current nozzle temperatures (one entry per nozzle). Multi-nozzle
258
+ # Creator 5 series report these; single-nozzle models use rightTemp/leftTemp.
259
+ nozzle_temps: list[float] | None = Field(default=None, alias="nozzleTemps")
260
+ # Per-tool target nozzle temperatures (one entry per nozzle).
261
+ nozzle_target_temps: list[float] | None = Field(default=None, alias="nozzleTargetTemps")
262
+ # Lidar / first-layer scanner presence flag (1 = present, 0 = absent).
263
+ lidar: int | None = Field(default=None, ge=0, alias="lidar")
253
264
  pid: int | None = Field(default=None, ge=0, alias="pid")
254
265
  plat_target_temp: float | None = Field(default=None, ge=-50, le=500, alias="platTargetTemp")
255
266
  plat_temp: float | None = Field(default=None, ge=-50, le=500, alias="platTemp")
@@ -327,11 +338,35 @@ class FFMachineInfo(BaseModel):
327
338
  pid: int | None = None
328
339
  is_pro: bool = False
329
340
  is_ad5x: bool = False
341
+ # Creator 5 / Creator 5 Pro (4-head tool-changer) detection. Drives the
342
+ # http_only transport decision and Pro-only capabilities (door sensor).
343
+ is_creator5: bool = False
344
+ is_creator5_pro: bool = False
345
+ # Immutable factory model name (e.g. "Creator 5 Pro"). Falls back to a
346
+ # PID-derived name, then the user-set `name`, when the printer doesn't
347
+ # report the `model` field.
348
+ model: str | None = None
330
349
  nozzle_size: str = ""
350
+ # Number of tools/nozzles the printer reports. Creator 5 series = 4
351
+ # (tool-changer); single-nozzle models = 1. Mirrors len(tool_temps).
352
+ nozzle_count: int | None = None
331
353
 
332
354
  # Temperatures
333
355
  print_bed: Temperature = Field(default_factory=Temperature)
334
356
  extruder: Temperature = Field(default_factory=Temperature)
357
+ # Heated chamber. Only the Creator 5 series has one; other models report 0/0.
358
+ chamber: Temperature | None = None
359
+ # Current/target temperatures for every tool/nozzle. Single-nozzle models
360
+ # report a 1-element array mirroring `extruder`; Creator 5 series report
361
+ # one entry per nozzle.
362
+ tool_temps: list[Temperature] = Field(default_factory=list)
363
+
364
+ # Capability flags (presence-derived, never assumed from model family)
365
+ has_camera: bool = False
366
+ has_lidar: bool = False
367
+ # Only true on models with confirmed hardware (Creator 5 Pro). When false,
368
+ # `door_open` is cosmetic and should not be surfaced.
369
+ has_door_sensor: bool = False
335
370
 
336
371
  # Current print stats
337
372
  print_duration: int = 0
@@ -164,6 +164,65 @@ class AD5XUploadParams(BaseModel):
164
164
  )
165
165
 
166
166
 
167
+ class Creator5JobParams(BaseModel):
168
+ """Parameters for starting a Creator 5 / Creator 5 Pro local print job.
169
+
170
+ Distinct from the AD5X job params: the Creator 5 maps materials at print-start
171
+ (POST /printGcode) rather than upload time, so the body carries NO
172
+ ``useMatlStation`` / ``gcodeToolCnt`` / ``firstLayerInspection`` (the latter
173
+ doesn't exist on the C5). ``flowCalibration`` and ``timeLapseVideo`` are always
174
+ present (default False); ``material_mappings`` is optional for a single-tool
175
+ print.
176
+ """
177
+
178
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
179
+
180
+ file_name: str = Field(description="Name of the file on the printer to start")
181
+ leveling_before_print: bool = Field(
182
+ description="Whether to perform bed leveling before printing"
183
+ )
184
+ flow_calibration: bool = Field(
185
+ default=False, description="Whether to enable flow calibration"
186
+ )
187
+ time_lapse_video: bool = Field(
188
+ default=False, description="Whether to enable time lapse video recording"
189
+ )
190
+ material_mappings: list[AD5XMaterialMapping] | None = Field(
191
+ default=None,
192
+ max_length=4,
193
+ description="Optional per-tool material mappings (1-4 items); omit for single-tool",
194
+ )
195
+
196
+
197
+ class Creator5UploadParams(BaseModel):
198
+ """Parameters for uploading a file to a Creator 5 / Creator 5 Pro.
199
+
200
+ Mirrors the AD5X upload but omits ``firstLayerInspection`` (absent on the C5)
201
+ and the ``materialMappings`` header (the C5 maps materials at print-start, not
202
+ upload). The C5 firmware checks the booleans as the string "true"/"false".
203
+ """
204
+
205
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
206
+
207
+ file_path: str = Field(description="Local file path to upload")
208
+ start_print: bool = Field(description="Whether to start printing immediately after upload")
209
+ leveling_before_print: bool = Field(
210
+ description="Whether to perform bed leveling before printing"
211
+ )
212
+ flow_calibration: bool = Field(
213
+ default=False, description="Whether to enable flow calibration"
214
+ )
215
+ time_lapse_video: bool = Field(
216
+ default=False, description="Whether to enable time lapse video recording"
217
+ )
218
+ use_matl_station: bool = Field(
219
+ description="Whether this is a multi-tool material-station job"
220
+ )
221
+ gcode_tool_cnt: int = Field(
222
+ ge=1, le=4, description="Number of tools in the G-code (1-4 for the C5)"
223
+ )
224
+
225
+
167
226
  class GCodeListResponse(GenericResponse):
168
227
  """Represents the response structure for a G-code file list request."""
169
228