flashforge-python-api 1.2.2__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.
flashforge/__init__.py CHANGED
@@ -127,7 +127,7 @@ from .tcp import (
127
127
  )
128
128
 
129
129
  FiveMClient = FlashForgeClient
130
- __version__ = "1.2.2"
130
+ __version__ = "1.3.0"
131
131
  __author__ = "FlashForge Python API Contributors"
132
132
  __email__ = "notghosttypes@gmail.com"
133
133
  __description__ = "Python library for controlling FlashForge 3D printers"
@@ -9,3 +9,7 @@ class Commands:
9
9
  CIRCULATION_CONTROL_CMD = "circulateCtl_cmd"
10
10
  CAMERA_CONTROL_CMD = "streamCtrl_cmd"
11
11
  TEMP_CONTROL_CMD = "temperatureCtl_cmd"
12
+ # AD5X + Creator 5 material-station slot metadata (name + color). Filament
13
+ # load/unload (`ms_cmd`) stays AD5X-only — the Creator 5 firmware has no
14
+ # `ms_cmd` (confirmed via Ghidra RE).
15
+ MATERIAL_STATION_CONFIG_CMD = "msConfig_cmd"
@@ -1,6 +1,5 @@
1
1
  from typing import Final
2
2
 
3
-
4
3
  CAMERA_STREAM_PORT: Final[int] = 8080
5
4
 
6
5
 
@@ -8,6 +8,7 @@ from ...models.responses import FilamentArgs
8
8
  from ..constants.commands import Commands
9
9
  from ..constants.endpoints import Endpoints
10
10
  from ..network.utils import NetworkUtils, json_from_response
11
+ from .creator5_palette import snap_to_creator5_palette
11
12
 
12
13
  if TYPE_CHECKING:
13
14
  from ...client import FlashForgeClient
@@ -43,8 +44,11 @@ class Control:
43
44
  Homes the X, Y, and Z axes of the printer.
44
45
 
45
46
  Returns:
46
- True if the command is successful, False otherwise.
47
+ True if the command is successful, False otherwise (including when the
48
+ printer has no TCP control channel, e.g. Creator 5).
47
49
  """
50
+ if not self.client.can_use_tcp("home_axes"):
51
+ return False
48
52
  return await self.tcp_client.home_axes()
49
53
 
50
54
  async def home_axes_rapid(self) -> bool:
@@ -52,8 +56,11 @@ class Control:
52
56
  Performs a rapid homing of the X, Y, and Z axes.
53
57
 
54
58
  Returns:
55
- True if the command is successful, False otherwise.
59
+ True if the command is successful, False otherwise (including when the
60
+ printer has no TCP control channel, e.g. Creator 5).
56
61
  """
62
+ if not self.client.can_use_tcp("home_axes_rapid"):
63
+ return False
57
64
  return await self.tcp_client.rapid_home()
58
65
 
59
66
  async def set_external_filtration_on(self) -> bool:
@@ -202,8 +209,11 @@ class Control:
202
209
  Turns on the filament runout sensor.
203
210
 
204
211
  Returns:
205
- True if the command is successful, False otherwise.
212
+ True if the command is successful, False otherwise (including when the
213
+ printer has no TCP control channel, e.g. Creator 5).
206
214
  """
215
+ if not self.client.can_use_tcp("turn_runout_sensor_on"):
216
+ return False
207
217
  return await self.tcp_client.turn_runout_sensor_on()
208
218
 
209
219
  async def turn_runout_sensor_off(self) -> bool:
@@ -211,10 +221,61 @@ class Control:
211
221
  Turns off the filament runout sensor.
212
222
 
213
223
  Returns:
214
- True if the command is successful, False otherwise.
224
+ True if the command is successful, False otherwise (including when the
225
+ printer has no TCP control channel, e.g. Creator 5).
215
226
  """
227
+ if not self.client.can_use_tcp("turn_runout_sensor_off"):
228
+ return False
216
229
  return await self.tcp_client.turn_runout_sensor_off()
217
230
 
231
+ async def configure_slot(self, slot: int, material_name: str, hex_rgb: str) -> bool:
232
+ """
233
+ Configures the material name and color metadata for a material-station slot.
234
+ This information is shown on the printer UI and used for print validation; it
235
+ does not move any filament. Available on the AD5X and the Creator 5 / Creator 5 Pro.
236
+
237
+ The ``msConfig_cmd`` handler is firmware-confirmed present on the Creator 5
238
+ (Ghidra RE of ``firmwareExe`` 1.9.2). The Creator 5 has no removable IFS; it
239
+ surfaces its 4 tool heads as the 4 "slots", so this sets per-tool material
240
+ metadata. Both models share the same ``OrcaServer`` command path and wire
241
+ format. Note that **filament load/unload (``slotAction`` / ``ms_cmd``)
242
+ remains AD5X-only** — the Creator 5 firmware has no ``ms_cmd``.
243
+
244
+ The firmware accepts arbitrary material strings, but the two models render
245
+ the slot color icon differently:
246
+
247
+ - AD5X: accepts freeform hex (the leading "#" is stripped before sending).
248
+ - Creator 5 / 5 Pro: renders an icon ONLY when ``rgb`` is a byte-for-byte,
249
+ case-sensitive match against the firmware's 24-entry palette (WITH the
250
+ "#"); any other value falls back to White. This method snaps the
251
+ caller's color to the nearest palette entry automatically for the Creator 5.
252
+
253
+ Args:
254
+ slot: The slot number (1-4).
255
+ material_name: The material type (e.g., "PLA", "PETG").
256
+ hex_rgb: The color as a hex string.
257
+
258
+ Returns:
259
+ True if the command is successful, False otherwise.
260
+ """
261
+ if not self.client.is_ad5x and not self.client.is_creator5:
262
+ print("configure_slot() error, material station only available on AD5X / Creator 5.")
263
+ return False
264
+ # The AD5X and Creator 5 use MUTUALLY EXCLUSIVE color wire formats (see
265
+ # creator5_palette for the firmware match rules), so model-gate here:
266
+ # - AD5X: freeform hex, the leading "#" stripped ("RRGGBB"). Unchanged.
267
+ # - Creator 5 / 5 Pro: the firmware renders an icon ONLY on a byte-for-byte
268
+ # match against its 24-entry palette (case-sensitive, WITH the "#"). Snap
269
+ # the caller's color to the nearest palette entry in uppercase "#RRGGBB".
270
+ if self.client.is_creator5:
271
+ rgb = snap_to_creator5_palette(hex_rgb).hex
272
+ else:
273
+ rgb = hex_rgb[1:] if hex_rgb.startswith("#") else hex_rgb
274
+ return await self.send_control_command(
275
+ Commands.MATERIAL_STATION_CONFIG_CMD,
276
+ {"slot": slot, "mt": material_name, "rgb": rgb},
277
+ )
278
+
218
279
  async def send_control_command(self, command: str, args: dict[str, Any]) -> bool:
219
280
  """
220
281
  Sends a generic control command to the printer via HTTP POST.
@@ -0,0 +1,255 @@
1
+ """
2
+ FlashForge Python API - Creator 5 / Creator 5 Pro material-station slot color
3
+ palette and perceptual nearest-color snapping.
4
+
5
+ The Creator 5 ``msConfig_cmd`` only renders a color icon when the ``rgb`` field is
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
9
+ DIFFER from the AD5X palette (e.g. Blue is ``#4CAAF8`` here vs ``#45A8F9`` on the
10
+ AD5X), so callers must snap against THIS list specifically.
11
+
12
+ By contrast the AD5X accepts freeform hex (with the ``#`` stripped), so the two
13
+ wire formats are mutually exclusive — see :func:`flashforge.api.controls.control.Control.configure_slot`
14
+ for the model-gating that splits them.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import math
20
+ import re
21
+ from dataclasses import dataclass
22
+
23
+ # The color-space math in this module mirrors the published CIEDE2000 / CIE L*a*b*
24
+ # notation (L, a, b, C', h', etc.) verbatim so it can be checked against the
25
+ # reference. PEP 8 lowercase locals are therefore relaxed here on purpose.
26
+ # ruff: noqa: N806
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Creator5PaletteColor:
31
+ """A single entry in the Creator 5 firmware color palette."""
32
+
33
+ index: int
34
+ """Firmware palette index (0 = White = the no-match fallback)."""
35
+
36
+ name: str
37
+ """Color name as shown on the printer UI."""
38
+
39
+ hex: str # noqa: N815 - field mirrors the TS `hex` property name
40
+ """Wire value sent to the printer, always uppercase ``#RRGGBB``."""
41
+
42
+
43
+ #: The firmware's 24-entry UI palette (firmwareExe 1.9.2, Ghidra-confirmed).
44
+ #: Index 0 (White) is also what the firmware falls back to on a no-match.
45
+ CREATOR5_PALETTE: tuple[Creator5PaletteColor, ...] = (
46
+ Creator5PaletteColor(0, "White", "#FFFFFF"),
47
+ Creator5PaletteColor(1, "Yellow", "#FFF245"),
48
+ Creator5PaletteColor(2, "Light Green", "#DEF578"),
49
+ Creator5PaletteColor(3, "Green", "#21CC3D"),
50
+ Creator5PaletteColor(4, "Dark Green", "#167A4B"),
51
+ Creator5PaletteColor(5, "Teal", "#156682"),
52
+ Creator5PaletteColor(6, "Cyan", "#24E4A0"),
53
+ Creator5PaletteColor(7, "Light Blue", "#7BD9F0"),
54
+ Creator5PaletteColor(8, "Blue", "#4CAAF8"),
55
+ Creator5PaletteColor(9, "Dark Blue", "#2E54DD"),
56
+ Creator5PaletteColor(10, "Purple", "#48358C"),
57
+ Creator5PaletteColor(11, "Violet", "#A341F7"),
58
+ Creator5PaletteColor(12, "Magenta", "#F435F6"),
59
+ Creator5PaletteColor(13, "Pink", "#D5B4DE"),
60
+ Creator5PaletteColor(14, "Coral", "#FA6173"),
61
+ Creator5PaletteColor(15, "Red", "#F82D29"),
62
+ Creator5PaletteColor(16, "Brown", "#805003"),
63
+ Creator5PaletteColor(17, "Orange", "#F9903B"),
64
+ Creator5PaletteColor(18, "Cream", "#FCEBD7"),
65
+ Creator5PaletteColor(19, "Tan", "#D5C5A1"),
66
+ Creator5PaletteColor(20, "Dark Brown", "#B17C38"),
67
+ Creator5PaletteColor(21, "Gray", "#8C8C89"),
68
+ Creator5PaletteColor(22, "Light Gray", "#BEBEBE"),
69
+ Creator5PaletteColor(23, "Black", "#1B1B1B"),
70
+ )
71
+
72
+ # 25^7, the CIEDE2000 chroma weighting constant.
73
+ _25_POW_7 = 25.0 ** 7
74
+
75
+ # D65 reference white point used by the sRGB -> XYZ transform.
76
+ _D65_XN = 0.95047
77
+ _D65_YN = 1.0
78
+ _D65_ZN = 1.08883
79
+
80
+
81
+ def _srgb_to_linear(channel: float) -> float:
82
+ """sRGB component (0-255) channel transfer function -> linear value (0-1)."""
83
+ c = channel / 255.0
84
+ return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
85
+
86
+
87
+ def _rgb_to_lab(r: float, g: float, b: float) -> tuple[float, float, float]:
88
+ """
89
+ Converts an sRGB color (0-255 channels) to CIE L*a*b* under a D65 illuminant.
90
+ Used as the perceptual basis for the CIEDE2000 nearest-color match.
91
+ """
92
+ R = _srgb_to_linear(r)
93
+ G = _srgb_to_linear(g)
94
+ B = _srgb_to_linear(b)
95
+
96
+ x = R * 0.4124564 + G * 0.3575761 + B * 0.1804375
97
+ y = R * 0.2126729 + G * 0.7151522 + B * 0.072175
98
+ z = R * 0.0193339 + G * 0.119192 + B * 0.9503041
99
+
100
+ x /= _D65_XN
101
+ y /= _D65_YN
102
+ z /= _D65_ZN
103
+
104
+ def f(t: float) -> float:
105
+ return math.cbrt(t) if t > 0.008856 else 7.787 * t + 16.0 / 116.0
106
+
107
+ fx = f(x)
108
+ fy = f(y)
109
+ fz = f(z)
110
+
111
+ return (116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz))
112
+
113
+
114
+ def _atan2_deg(ordinate: float, abscissa: float) -> float:
115
+ """atan2 -> hue in degrees, normalized to [0, 360)."""
116
+ h = math.degrees(math.atan2(ordinate, abscissa))
117
+ if h < 0:
118
+ h += 360.0
119
+ return h
120
+
121
+
122
+ def _delta_e_2000(c1: tuple[float, float, float], c2: tuple[float, float, float]) -> float:
123
+ """
124
+ CIEDE2000 color difference between two L*a*b* colors (kL=kC=kH=1). This is the
125
+ most accurate standard delta-E metric and is preferred here because the
126
+ firmware renders only an exact palette match — snapping to the wrong
127
+ perceptual neighbor would display the wrong color on the printer.
128
+ """
129
+ L1, a1, b1 = c1
130
+ L2, a2, b2 = c2
131
+
132
+ C1 = math.sqrt(a1 * a1 + b1 * b1)
133
+ C2 = math.sqrt(a2 * a2 + b2 * b2)
134
+ Cbar = (C1 + C2) / 2.0
135
+ Cbar7 = Cbar ** 7
136
+ G = 0.5 * (1 - math.sqrt(Cbar7 / (Cbar7 + _25_POW_7)))
137
+
138
+ a1p = (1 + G) * a1
139
+ a2p = (1 + G) * a2
140
+ C1p = math.sqrt(a1p * a1p + b1 * b1)
141
+ C2p = math.sqrt(a2p * a2p + b2 * b2)
142
+ h1p = _atan2_deg(b1, a1p)
143
+ h2p = _atan2_deg(b2, a2p)
144
+
145
+ dLp = L2 - L1
146
+ dCp = C2p - C1p
147
+ if C1p * C2p == 0:
148
+ dhp = 0.0
149
+ else:
150
+ diff = h2p - h1p
151
+ if abs(diff) <= 180:
152
+ dhp = diff
153
+ elif diff > 180:
154
+ dhp = diff - 360
155
+ else:
156
+ dhp = diff + 360
157
+ dHp = 2 * math.sqrt(C1p * C2p) * math.sin(math.radians(dhp) / 2.0)
158
+
159
+ Lbarp = (L1 + L2) / 2.0
160
+ Cbarp = (C1p + C2p) / 2.0
161
+ if C1p * C2p == 0:
162
+ hbarp = h1p + h2p
163
+ else:
164
+ diff = abs(h1p - h2p)
165
+ if diff <= 180:
166
+ hbarp = (h1p + h2p) / 2.0
167
+ elif h1p + h2p < 360:
168
+ hbarp = (h1p + h2p + 360) / 2.0
169
+ else:
170
+ hbarp = (h1p + h2p - 360) / 2.0
171
+
172
+ T = (
173
+ 1
174
+ - 0.17 * math.cos(math.radians(hbarp - 30))
175
+ + 0.24 * math.cos(math.radians(2 * hbarp))
176
+ + 0.32 * math.cos(math.radians(3 * hbarp + 6))
177
+ - 0.20 * math.cos(math.radians(4 * hbarp - 63))
178
+ )
179
+
180
+ dTheta = 30 * math.exp(-(((hbarp - 275) / 25.0) ** 2))
181
+ Cbarp7 = Cbarp ** 7
182
+ RC = 2 * math.sqrt(Cbarp7 / (Cbarp7 + _25_POW_7))
183
+ SL = 1 + (0.015 * (Lbarp - 50) ** 2) / math.sqrt(20 + (Lbarp - 50) ** 2)
184
+ SC = 1 + 0.045 * Cbarp
185
+ SH = 1 + 0.015 * Cbarp * T
186
+ RT = -math.sin(math.radians(2 * dTheta)) * RC
187
+
188
+ termL = dLp / SL
189
+ termC = dCp / SC
190
+ termH = dHp / SH
191
+
192
+ return math.sqrt(termL * termL + termC * termC + termH * termH + RT * termC * termH)
193
+
194
+
195
+ _HEX_RE = re.compile(r"^[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$")
196
+
197
+
198
+ def _hex_to_rgb(hex_str: str) -> tuple[int, int, int] | None:
199
+ """
200
+ Parses a hex color string (``#RRGGBB``, ``RRGGBB``, 3-digit shorthand, any
201
+ case) into its RGB channels. Returns None for unparseable input.
202
+ """
203
+ clean = hex_str.strip()
204
+ # Remove a single leading "#" (matches the TS replace(/^#/, '')).
205
+ if clean.startswith("#"):
206
+ clean = clean[1:]
207
+ if not _HEX_RE.match(clean):
208
+ return None
209
+ if len(clean) == 3:
210
+ clean = "".join(ch + ch for ch in clean)
211
+ return (int(clean[0:2], 16), int(clean[2:4], 16), int(clean[4:6], 16))
212
+
213
+
214
+ # Palette entries with their L*a*b* values precomputed once at module load.
215
+ _PALETTE_LAB: list[tuple[Creator5PaletteColor, tuple[float, float, float]]] = []
216
+ for _color in CREATOR5_PALETTE:
217
+ _rgb = _hex_to_rgb(_color.hex)
218
+ if _rgb is None:
219
+ _rgb = (0, 0, 0)
220
+ _PALETTE_LAB.append((_color, _rgb_to_lab(_rgb[0], _rgb[1], _rgb[2])))
221
+
222
+
223
+ def snap_to_creator5_palette(hex_str: str) -> Creator5PaletteColor:
224
+ """
225
+ Snaps an arbitrary hex color to the nearest entry in the Creator 5 firmware
226
+ palette using the CIEDE2000 perceptual distance in CIE L*a*b* space.
227
+
228
+ The returned :attr:`Creator5PaletteColor.hex` is always uppercase ``#RRGGBB``
229
+ and is guaranteed to be a byte-for-byte firmware match. Unparseable input
230
+ falls back to White (index 0, the firmware's own no-match fallback) with a
231
+ warning.
232
+
233
+ Args:
234
+ hex_str: The caller's color as a hex string (leading ``#`` optional, any case).
235
+
236
+ Returns:
237
+ The nearest Creator 5 palette entry.
238
+ """
239
+ rgb = _hex_to_rgb(hex_str)
240
+ if rgb is None:
241
+ print(
242
+ f'snap_to_creator5_palette: could not parse "{hex_str}" as hex; '
243
+ "falling back to White."
244
+ )
245
+ return CREATOR5_PALETTE[0]
246
+
247
+ target = _rgb_to_lab(rgb[0], rgb[1], rgb[2])
248
+ best = _PALETTE_LAB[0]
249
+ best_delta = float("inf")
250
+ for entry in _PALETTE_LAB:
251
+ delta = _delta_e_2000(target, entry[1])
252
+ if delta < best_delta:
253
+ best_delta = delta
254
+ best = entry
255
+ return best[0]
@@ -35,10 +35,18 @@ class Files:
35
35
  """
36
36
  Retrieves a list of files stored locally on the printer.
37
37
 
38
+ HTTP-only printers (Creator 5 / 5 Pro) have no TCP/8899 file-listing
39
+ channel, so this falls back to the HTTP ``/gcodeList`` recent-file list
40
+ (mirroring ``Files.ts``) instead of hanging on a dead TCP socket.
41
+
38
42
  Returns:
39
43
  A list of file names, or empty list if retrieval fails.
40
44
  """
41
- # This method uses the TCP client to get file list
45
+ if self.client.http_only:
46
+ entries = await self.get_recent_file_list()
47
+ return [e.gcode_file_name for e in entries if e.gcode_file_name]
48
+
49
+ # Legacy / 5M-family printers expose the file list over TCP.
42
50
  if hasattr(self.client, "tcp_client") and self.client.tcp_client:
43
51
  return await self.client.tcp_client.get_file_list_async()
44
52
  return []
@@ -47,6 +55,9 @@ class Files:
47
55
  """
48
56
  Retrieves a list of files stored locally on the printer.
49
57
 
58
+ HTTP-only printers (Creator 5 / 5 Pro) have no TCP/8899 file-listing
59
+ channel; see :meth:`get_file_list` for the HTTP fallback.
60
+
50
61
  Returns:
51
62
  A list of file names, or empty list if retrieval fails.
52
63
  """
@@ -2,6 +2,7 @@
2
2
  FlashForge Python API - Info Module
3
3
  """
4
4
 
5
+ import re
5
6
  from datetime import datetime, timedelta
6
7
  from typing import TYPE_CHECKING
7
8
 
@@ -14,6 +15,32 @@ if TYPE_CHECKING:
14
15
  from ...client import FlashForgeClient
15
16
 
16
17
 
18
+ # Firmware-reported PIDs from FlashForge's /detail endpoint. These are stable
19
+ # identifiers set by firmware, unlike the user-mutable `name` field.
20
+ PID_5M = 35
21
+ PID_5M_PRO = 36
22
+ PID_AD5X = 38
23
+ PID_CREATOR5 = 40
24
+ PID_CREATOR5_PRO = 41
25
+ KNOWN_HTTP_PIDS = {
26
+ PID_5M,
27
+ PID_5M_PRO,
28
+ PID_AD5X,
29
+ PID_CREATOR5,
30
+ PID_CREATOR5_PRO,
31
+ }
32
+
33
+ # Immutable model display names keyed by firmware PID. Used as a fallback for
34
+ # `model` when the printer doesn't report the `model` field (older firmware).
35
+ PID_MODEL_NAMES: dict[int, str] = {
36
+ PID_5M: "Adventurer 5M",
37
+ PID_5M_PRO: "Adventurer 5M Pro",
38
+ PID_AD5X: "AD5X",
39
+ PID_CREATOR5: "Creator 5",
40
+ PID_CREATOR5_PRO: "Creator 5 Pro",
41
+ }
42
+
43
+
17
44
  class MachineInfoParser:
18
45
  """
19
46
  Transforms printer detail data from the API response format into a structured FFMachineInfo object.
@@ -68,8 +95,74 @@ class MachineInfoParser:
68
95
  or len(getattr(getattr(detail, "matl_station_info", None), "slot_infos", []) or []) > 0
69
96
  )
70
97
  printer_name = getattr(detail, "name", "") or ""
71
- is_ad5x = printer_name.upper() == "AD5X" or has_material_station
72
- is_pro = "Pro" in printer_name and not is_ad5x
98
+ pid = getattr(detail, "pid", None)
99
+ detail_model = getattr(detail, "model", None) or ""
100
+
101
+ if pid in KNOWN_HTTP_PIDS:
102
+ is_ad5x = pid == PID_AD5X
103
+ is_pro = pid == PID_5M_PRO
104
+ is_creator5 = pid in (PID_CREATOR5, PID_CREATOR5_PRO)
105
+ is_creator5_pro = pid == PID_CREATOR5_PRO
106
+ else:
107
+ # Fallback for firmware that doesn't report pid: legacy
108
+ # name+capability heuristic. Vulnerable to user renames, which
109
+ # is why pid-based detection is preferred when available.
110
+ #
111
+ # IMPORTANT: detect the Creator 5 family *first*. A user-set
112
+ # name like "Creator 5 Pro" contains "Pro", so a naive
113
+ # `"Pro" in name` check would mis-classify it as a 5M Pro.
114
+ is_creator5 = "Creator 5" in printer_name
115
+ is_creator5_pro = is_creator5 and bool(
116
+ re.search(r"Creator 5 Pro", detail_model or printer_name, re.IGNORECASE)
117
+ )
118
+ is_ad5x = (
119
+ printer_name.upper() == "AD5X" or has_material_station
120
+ ) and not is_creator5
121
+ is_pro = "Pro" in printer_name and not is_ad5x and not is_creator5
122
+
123
+ # Per-tool temperatures. Creator 5 series report nozzleTemps[] /
124
+ # nozzleTargetTemps[]; single-nozzle models don't, so fall back to a
125
+ # 1-element array mirroring the right/main extruder.
126
+ nozzle_temps = getattr(detail, "nozzle_temps", None)
127
+ nozzle_target_temps = getattr(detail, "nozzle_target_temps", None)
128
+ if nozzle_temps:
129
+ tool_temps = [
130
+ Temperature(
131
+ current=t or 0.0,
132
+ set=(
133
+ nozzle_target_temps[i]
134
+ if nozzle_target_temps and i < len(nozzle_target_temps)
135
+ else 0.0
136
+ )
137
+ or 0.0,
138
+ )
139
+ for i, t in enumerate(nozzle_temps)
140
+ ]
141
+ else:
142
+ tool_temps = [
143
+ Temperature(
144
+ current=getattr(detail, "right_temp", 0) or 0.0,
145
+ set=getattr(detail, "right_target_temp", 0) or 0.0,
146
+ )
147
+ ]
148
+
149
+ # Capability flags. Derived from presence/value, never assumed from
150
+ # the model family alone. Only the Creator 5 Pro has a confirmed
151
+ # door sensor; on every other model `doorStatus` is cosmetic.
152
+ has_camera = getattr(detail, "camera", None) == 1 or bool(
153
+ getattr(detail, "camera_stream_url", "") or ""
154
+ )
155
+ has_lidar = getattr(detail, "lidar", None) == 1
156
+ has_door_sensor = is_creator5_pro
157
+
158
+ # Immutable model name resolution: prefer the firmware `model`
159
+ # field, then a PID-derived name, then the user-set name.
160
+ pid_model_name = PID_MODEL_NAMES.get(pid) if pid is not None else None
161
+ model_value = detail_model or pid_model_name or printer_name or ""
162
+
163
+ # Prefer the firmware-reported nozzle count; fall back to the parsed
164
+ # per-tool array length so single-nozzle models still report 1.
165
+ nozzle_count = getattr(detail, "nozzle_cnt", None) or len(tool_temps)
73
166
 
74
167
  # Build the FFMachineInfo object
75
168
  machine_info = FFMachineInfo(
@@ -107,9 +200,14 @@ class MachineInfoParser:
107
200
  fill_amount=getattr(detail, "fill_amount", 0) or 0,
108
201
  firmware_version=getattr(detail, "firmware_version", "") or "",
109
202
  name=printer_name,
203
+ pid=pid,
110
204
  is_pro=is_pro,
111
205
  is_ad5x=is_ad5x,
206
+ is_creator5=is_creator5,
207
+ is_creator5_pro=is_creator5_pro,
208
+ model=model_value,
112
209
  nozzle_size=getattr(detail, "nozzle_model", "") or "",
210
+ nozzle_count=nozzle_count,
113
211
  # Temperatures
114
212
  print_bed=Temperature(
115
213
  current=getattr(detail, "plat_temp", 0) or 0,
@@ -119,6 +217,15 @@ class MachineInfoParser:
119
217
  current=getattr(detail, "right_temp", 0) or 0,
120
218
  set=getattr(detail, "right_target_temp", 0) or 0,
121
219
  ),
220
+ chamber=Temperature(
221
+ current=getattr(detail, "chamber_temp", 0) or 0,
222
+ set=getattr(detail, "chamber_target_temp", 0) or 0,
223
+ ),
224
+ tool_temps=tool_temps,
225
+ # Capability flags (presence-derived)
226
+ has_camera=has_camera,
227
+ has_lidar=has_lidar,
228
+ has_door_sensor=has_door_sensor,
122
229
  # Current print stats
123
230
  print_duration=getattr(detail, "print_duration", 0) or 0,
124
231
  print_file_name=getattr(detail, "print_file_name", "") or "",