flashforge-python-api 1.3.3__py3-none-any.whl → 1.3.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
flashforge/__init__.py CHANGED
@@ -69,6 +69,7 @@ from .discovery import (
69
69
  PrinterStatus,
70
70
  SocketCreationError,
71
71
  )
72
+ from .exceptions import FlashForgeError, FlashForgeResponseError
72
73
 
73
74
  # Import key models for convenience
74
75
  from .models import (
@@ -127,7 +128,7 @@ from .tcp import (
127
128
  )
128
129
 
129
130
  FiveMClient = FlashForgeClient
130
- __version__ = "1.3.1"
131
+ __version__ = "1.3.4"
131
132
  __author__ = "FlashForge Python API Contributors"
132
133
  __email__ = "notghosttypes@gmail.com"
133
134
  __description__ = "Python library for controlling FlashForge 3D printers"
@@ -138,6 +139,9 @@ __all__ = [
138
139
  "FlashForgeClient",
139
140
  "FiveMClient",
140
141
  "FiveMClientConnectionOptions",
142
+ # Exceptions
143
+ "FlashForgeError",
144
+ "FlashForgeResponseError",
141
145
  # Data models
142
146
  "FFMachineInfo",
143
147
  "FFPrinterDetail",
@@ -5,8 +5,9 @@ FlashForge Python API - Info Module
5
5
  import logging
6
6
  import re
7
7
  from datetime import datetime, timedelta
8
- from typing import TYPE_CHECKING
8
+ from typing import TYPE_CHECKING, Any
9
9
 
10
+ from ...exceptions import FlashForgeResponseError
10
11
  from ...models.machine_info import FFMachineInfo, MachineState, Temperature
11
12
  from ...models.responses import DetailResponse, FFPrinterDetail
12
13
  from ..constants.endpoints import Endpoints
@@ -165,6 +166,12 @@ class MachineInfoParser:
165
166
  )
166
167
  has_lidar = getattr(detail, "lidar", None) == 1
167
168
  has_door_sensor = is_creator5_pro
169
+ # Not every Creator 5 has the heated chamber. FFPrinterDetail maps
170
+ # the firmware's "no sensor" sentinel (-108) to None, so an absent
171
+ # reading is the signal - never the model family, which is what left
172
+ # chamber-less units with three entities pinned at 0 C.
173
+ chamber_temp_value = getattr(detail, "chamber_temp", None)
174
+ has_chamber_sensor = chamber_temp_value is not None
168
175
 
169
176
  # Immutable model name resolution: prefer the firmware `model`
170
177
  # field, then a PID-derived name, then the user-set name.
@@ -237,6 +244,7 @@ class MachineInfoParser:
237
244
  has_camera=has_camera,
238
245
  has_lidar=has_lidar,
239
246
  has_door_sensor=has_door_sensor,
247
+ has_chamber_sensor=has_chamber_sensor,
240
248
  # Current print stats
241
249
  print_duration=getattr(detail, "print_duration", 0) or 0,
242
250
  print_file_name=getattr(detail, "print_file_name", "") or "",
@@ -338,21 +346,41 @@ class Info:
338
346
  This method fetches detailed data from the printer and transforms it.
339
347
 
340
348
  Returns:
341
- An FFMachineInfo object, or None if an error occurs or no data is returned.
349
+ An FFMachineInfo object, or None if the printer could not be reached.
350
+
351
+ Raises:
352
+ FlashForgeResponseError: The printer answered but the payload could
353
+ not be understood. Distinct from a None return, which means the
354
+ request never got through.
342
355
  """
343
356
  detail_response = await self.get_detail_response()
344
- if detail_response and detail_response.detail:
345
- return MachineInfoParser.from_detail(detail_response.detail)
346
- return None
357
+ if detail_response is None or detail_response.detail is None:
358
+ return None
359
+
360
+ machine_info = MachineInfoParser.from_detail(detail_response.detail)
361
+ if machine_info is None:
362
+ # We validated the payload but could not build our own model from
363
+ # it - that is a bug on our side, not an unreachable printer, and
364
+ # reporting it as the latter is what issue #18 was about.
365
+ raise FlashForgeResponseError(
366
+ "The /detail payload was read but could not be converted into machine info",
367
+ endpoint=Endpoints.DETAIL,
368
+ )
369
+ return machine_info
347
370
 
348
371
  async def is_printing(self) -> bool:
349
372
  """
350
373
  Checks if the printer is currently in the "printing" state.
351
374
 
375
+ These three convenience wrappers keep their documented "return the
376
+ fallback on any failure" contract, so a caller polling for a boolean is
377
+ not forced to handle an exception. The failure is still logged, and
378
+ callers that need to distinguish causes should use `get()` directly.
379
+
352
380
  Returns:
353
381
  True if the printer is printing, False otherwise or if status cannot be determined.
354
382
  """
355
- info = await self.get()
383
+ info = await self._get_or_none()
356
384
  return info.status == "printing" if info else False
357
385
 
358
386
  async def get_status(self) -> str | None:
@@ -362,7 +390,7 @@ class Info:
362
390
  Returns:
363
391
  The status string, or None if it cannot be determined.
364
392
  """
365
- info = await self.get()
393
+ info = await self._get_or_none()
366
394
  return info.status if info else None
367
395
 
368
396
  async def get_machine_state(self) -> MachineState | None:
@@ -372,17 +400,34 @@ class Info:
372
400
  Returns:
373
401
  A MachineState enum value, or None if it cannot be determined.
374
402
  """
375
- info = await self.get()
403
+ info = await self._get_or_none()
376
404
  return info.machine_state if info else None
377
405
 
378
- async def get_detail_response(self) -> DetailResponse | None:
406
+ async def _get_or_none(self) -> FFMachineInfo | None:
407
+ """Call `get()`, absorbing a response error into a None for the convenience wrappers."""
408
+ try:
409
+ return await self.get()
410
+ except FlashForgeResponseError as error:
411
+ logger.warning("Could not read machine info: %s", error)
412
+ return None
413
+
414
+ async def get_detail_raw(self) -> dict[str, Any] | None:
379
415
  """
380
- Retrieves the raw detailed response from the printer's detail endpoint.
381
- This contains a wealth of information about the printer's current state.
416
+ Retrieves the /detail response as the plain decoded JSON dict, with no
417
+ model validation applied.
418
+
419
+ This exists so callers can inspect identity fields - above all `pid` -
420
+ without first having to successfully validate the ~50 unrelated fields
421
+ in the payload. A supported printer must never be rejected because of a
422
+ field that has nothing to do with whether it is supported (issue #18).
382
423
 
383
424
  Returns:
384
- A DetailResponse object containing the raw printer details,
385
- or None if the request fails or an error occurs.
425
+ The decoded response body, or None if the printer could not be
426
+ reached or answered with a non-200 status.
427
+
428
+ Raises:
429
+ FlashForgeResponseError: The printer answered with a body that is
430
+ not decodable JSON.
386
431
  """
387
432
  payload = {"serialNumber": self.client.serial_number, "checkCode": self.client.check_code}
388
433
 
@@ -396,15 +441,57 @@ class Info:
396
441
  if response.status != 200:
397
442
  logger.warning("Non-200 status from the /detail endpoint: %s", response.status)
398
443
  return None
399
-
400
444
  data = await json_from_response(response)
401
- return DetailResponse(**data)
402
-
403
445
  except Exception as error:
404
446
  logger.warning(
405
- "Could not read /detail; callers cannot tell this from an unreachable "
406
- "printer or a rejected check code, so this line is the only record of "
407
- "the real cause. %s",
447
+ "Could not reach the printer's /detail endpoint. This is a transport "
448
+ "failure (unreachable printer, timeout, or refused connection), not a "
449
+ "payload the library failed to read. %s",
408
450
  error,
409
451
  )
410
452
  return None
453
+
454
+ if not isinstance(data, dict):
455
+ raise FlashForgeResponseError(
456
+ f"The /detail endpoint returned {type(data).__name__}, expected a JSON object",
457
+ endpoint=Endpoints.DETAIL,
458
+ )
459
+ return data
460
+
461
+ async def get_detail_response(self) -> DetailResponse | None:
462
+ """
463
+ Retrieves the validated detailed response from the printer's detail endpoint.
464
+ This contains a wealth of information about the printer's current state.
465
+
466
+ Returns:
467
+ A DetailResponse object containing the raw printer details, or None
468
+ if the printer could not be reached.
469
+
470
+ Raises:
471
+ FlashForgeResponseError: The printer answered, but the body did not
472
+ validate. Callers should surface this differently from a None
473
+ return: None means "we never got an answer", this means "the
474
+ answer was not one we could read", and only the second is
475
+ actionable as a bug report.
476
+ """
477
+ data = await self.get_detail_raw()
478
+ if data is None:
479
+ return None
480
+
481
+ try:
482
+ return DetailResponse(**data)
483
+ except Exception as error:
484
+ logger.warning(
485
+ "The printer answered /detail with a payload that failed validation. This "
486
+ "is NOT a connectivity problem - please report it with the debug dump "
487
+ "below. %s",
488
+ error,
489
+ )
490
+ # Redacted: this dump carries the MAC, IP and cloud registration
491
+ # codes, and it exists to be pasted into a bug report.
492
+ logger.debug("Payload that failed validation: %s", redact_model(data))
493
+ raise FlashForgeResponseError(
494
+ "The printer's /detail response could not be validated",
495
+ endpoint=Endpoints.DETAIL,
496
+ cause=error,
497
+ ) from error
@@ -0,0 +1,51 @@
1
+ """
2
+ FlashForge Python API - Exceptions
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+
8
+ class FlashForgeError(Exception):
9
+ """Base class for every error this library raises deliberately."""
10
+
11
+
12
+ class FlashForgeResponseError(FlashForgeError):
13
+ """
14
+ The printer answered, but the payload could not be understood.
15
+
16
+ This is deliberately distinct from a ``None`` return, which every HTTP
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
19
+ diagnose: a Creator 5 reporting ``chamberTemp: -108`` (the firmware's "no
20
+ chamber sensor" sentinel) surfaced in Home Assistant as ``cannot_connect``,
21
+ pointing the user at their network for a schema problem.
22
+
23
+ Raise this whenever the transport succeeded and the *content* was the
24
+ problem, so callers can tell the user something actionable.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ message: str,
30
+ *,
31
+ endpoint: str | None = None,
32
+ cause: Exception | None = None,
33
+ ) -> None:
34
+ """
35
+ Args:
36
+ message: Human-readable description of what could not be parsed.
37
+ endpoint: The endpoint whose response failed, e.g. "/detail".
38
+ cause: The underlying validation/decode error, kept for logs.
39
+ """
40
+ super().__init__(message)
41
+ self.message = message
42
+ self.endpoint = endpoint
43
+ self.cause = cause
44
+
45
+ def __str__(self) -> str:
46
+ parts = [self.message]
47
+ if self.endpoint:
48
+ parts.append(f"(endpoint: {self.endpoint})")
49
+ if self.cause:
50
+ parts.append(f"caused by {type(self.cause).__name__}: {self.cause}")
51
+ return " ".join(parts)
@@ -4,11 +4,76 @@ FlashForge Python API - Data Models
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
+ import re
7
8
  from datetime import datetime
8
9
  from enum import Enum
10
+ from typing import Any
9
11
 
10
12
  from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
11
13
 
14
+ # ---------------------------------------------------------------------------
15
+ # Validation policy for inbound firmware payloads
16
+ # ---------------------------------------------------------------------------
17
+ #
18
+ # Models parsed FROM the printer carry types but NOT value ranges, and no
19
+ # required fields beyond what we genuinely cannot work without. The reason is
20
+ # blast radius: pydantic fails a model as a unit, and `Info.get_detail_response`
21
+ # turns any failure into "no data", which Home Assistant shows as an offline
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
24
+ # `chamberTemp: -108` made every entity unavailable and the config flow report
25
+ # `cannot_connect`.
26
+ #
27
+ # Range constraints belong on OUTBOUND command models (see responses.py), where
28
+ # the values are ours and a bad one is our bug. Inbound, we take what we need
29
+ # and tolerate the rest. This mirrors the TypeScript client (ff-5mp-api-ts),
30
+ # which has never hit this class of failure.
31
+ #
32
+ # Firmware signals "this sensor does not exist" with an out-of-band negative
33
+ # sentinel rather than by omitting the field: -108 on a chamber-less Creator 5,
34
+ # and -100 is our own "heater off" value (TempControl.TEMP_OFF). Anything at or
35
+ # below this floor is a marker, not a reading.
36
+ TEMP_SENTINEL_FLOOR = -50.0
37
+
38
+ _HEX_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}$")
39
+
40
+
41
+ def sanitize_temperature(value: Any) -> Any:
42
+ """
43
+ Map firmware temperature sentinels to None, passing everything else through.
44
+
45
+ Non-numeric input is returned untouched so normal type validation still
46
+ reports it. Used as a `mode="before"` validator on every inbound
47
+ temperature field.
48
+ """
49
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
50
+ return value
51
+ return None if value <= TEMP_SENTINEL_FLOOR else value
52
+
53
+
54
+ def _normalize_hex_color(value: Any) -> Any:
55
+ """
56
+ Coerce a material color into `#RRGGBB`, or an empty string when it is not
57
+ recognizable.
58
+
59
+ Never raises. An unparseable color costs a swatch its color; it must not
60
+ cost the caller the entire /detail response.
61
+ """
62
+ if not isinstance(value, str):
63
+ return "" if value is None else value
64
+
65
+ candidate = value.strip()
66
+ if candidate == "" or _HEX_COLOR_RE.match(candidate):
67
+ return candidate
68
+
69
+ # Accept the shorthand `#RGB` form by expanding it, and a bare `RRGGBB`
70
+ # without the leading hash - both are plausible firmware variations.
71
+ if re.match(r"^#[0-9A-Fa-f]{3}$", candidate):
72
+ return "#" + "".join(char * 2 for char in candidate[1:])
73
+ if re.match(r"^[0-9A-Fa-f]{6}$", candidate):
74
+ return f"#{candidate}"
75
+ return ""
76
+
12
77
 
13
78
  class MachineState(Enum):
14
79
  """Enumerates the possible operational states of the FlashForge 3D printer."""
@@ -35,12 +100,11 @@ class Temperature(BaseModel):
35
100
  # `extra="allow"` would silently absorb.
36
101
  model_config = ConfigDict(extra="forbid")
37
102
 
38
- current: float = Field(
39
- default=0.0, ge=-50, le=500, description="The current temperature in Celsius"
40
- )
41
- set: float = Field(
42
- default=0.0, ge=-50, le=500, description="The target (set) temperature in Celsius"
43
- )
103
+ # No range constraints: MachineInfoParser feeds these straight from firmware
104
+ # values, so a range here fails `from_detail` for the same reason a range on
105
+ # FFPrinterDetail fails the response - one odd reading, no data at all.
106
+ current: float = Field(default=0.0, description="The current temperature in Celsius")
107
+ set: float = Field(default=0.0, description="The target (set) temperature in Celsius")
44
108
 
45
109
 
46
110
  class SlotInfo(BaseModel):
@@ -53,26 +117,32 @@ class SlotInfo(BaseModel):
53
117
  # integration reports as "could not retrieve printer information".
54
118
  model_config = ConfigDict(extra="allow", populate_by_name=True)
55
119
 
120
+ # Every field defaulted: a slot missing one attribute must cost that
121
+ # attribute, not the whole /detail response. `slot_id` carries no 1-4 range
122
+ # for the same reason - a station reporting a fifth slot should show up as a
123
+ # slot we ignore, not as an offline printer.
56
124
  has_filament: bool = Field(
57
- alias="hasFilament", description="Indicates if filament is present in this slot"
125
+ default=False,
126
+ alias="hasFilament",
127
+ description="Indicates if filament is present in this slot",
58
128
  )
59
129
  material_color: str = Field(
60
- alias="materialColor", description="Color of the material in this slot (e.g., '#FFFFFF')"
130
+ default="",
131
+ alias="materialColor",
132
+ description="Color of the material in this slot (e.g., '#FFFFFF')",
61
133
  )
62
134
  material_name: str = Field(
63
- alias="materialName", description="Name of the material in this slot (e.g., 'PLA')"
135
+ default="",
136
+ alias="materialName",
137
+ description="Name of the material in this slot (e.g., 'PLA')",
64
138
  )
65
- slot_id: int = Field(alias="slotId", ge=1, le=4, description="Identifier for this slot (1-4)")
139
+ slot_id: int = Field(default=0, alias="slotId", description="Identifier for this slot (1-4)")
66
140
 
67
- @field_validator("material_color")
141
+ @field_validator("material_color", mode="before")
68
142
  @classmethod
69
- def validate_hex_color(cls, v: str) -> str:
70
- """Validate that the color is a valid hex color code or empty string."""
71
- import re
72
-
73
- if v == "" or re.match(r"^#[0-9A-Fa-f]{6}$", v):
74
- return v
75
- raise ValueError(f"Invalid hex color format: {v}")
143
+ def normalize_material_color(cls, v: Any) -> Any:
144
+ """Normalize the material color, falling back to '' rather than raising."""
145
+ return _normalize_hex_color(v)
76
146
 
77
147
 
78
148
  class MatlStationInfo(BaseModel):
@@ -81,27 +151,29 @@ class MatlStationInfo(BaseModel):
81
151
  # `extra="allow"`: inbound, see SlotInfo.
82
152
  model_config = ConfigDict(extra="allow", populate_by_name=True)
83
153
 
154
+ # Defaulted and unconstrained throughout, per the inbound policy at the top
155
+ # of this module. `slot_infos` in particular had `min_length=1`, which made
156
+ # an explicitly empty `slotInfos: []` - a station with nothing loaded - fail
157
+ # the entire /detail response.
84
158
  current_load_slot: int = Field(
85
- alias="currentLoadSlot", ge=0, le=4, description="Currently loading slot ID (0 if none)"
159
+ default=0, alias="currentLoadSlot", description="Currently loading slot ID (0 if none)"
86
160
  )
87
161
  current_slot: int = Field(
88
- alias="currentSlot", ge=0, le=4, description="Currently active/printing slot ID (0 if none)"
162
+ default=0, alias="currentSlot", description="Currently active/printing slot ID (0 if none)"
89
163
  )
90
164
  slot_cnt: int = Field(
91
- alias="slotCnt", ge=1, le=4, description="Total number of slots in the station"
165
+ default=0, alias="slotCnt", description="Total number of slots in the station"
92
166
  )
93
167
  slot_infos: list[SlotInfo] = Field(
94
- min_length=1,
95
- max_length=4,
96
168
  default_factory=list,
97
169
  alias="slotInfos",
98
170
  description="Array of information for each slot",
99
171
  )
100
172
  state_action: int = Field(
101
- alias="stateAction", ge=0, description="Current action state of the material station"
173
+ default=0, alias="stateAction", description="Current action state of the material station"
102
174
  )
103
175
  state_step: int = Field(
104
- alias="stateStep", ge=0, description="Current step within the state action"
176
+ default=0, alias="stateStep", description="Current step within the state action"
105
177
  )
106
178
 
107
179
 
@@ -111,24 +183,24 @@ class IndepMatlInfo(BaseModel):
111
183
  # `extra="allow"`: inbound, see SlotInfo.
112
184
  model_config = ConfigDict(extra="allow", populate_by_name=True)
113
185
 
114
- material_color: str = Field(alias="materialColor", description="Color of the material")
186
+ material_color: str = Field(
187
+ default="", alias="materialColor", description="Color of the material"
188
+ )
115
189
  material_name: str = Field(
116
- alias="materialName", description="Name of the material (can be '?' if unknown)"
190
+ default="",
191
+ alias="materialName",
192
+ description="Name of the material (can be '?' if unknown)",
117
193
  )
118
- state_action: int = Field(alias="stateAction", ge=0, description="Current action state")
194
+ state_action: int = Field(default=0, alias="stateAction", description="Current action state")
119
195
  state_step: int = Field(
120
- alias="stateStep", ge=0, description="Current step within the state action"
196
+ default=0, alias="stateStep", description="Current step within the state action"
121
197
  )
122
198
 
123
- @field_validator("material_color")
199
+ @field_validator("material_color", mode="before")
124
200
  @classmethod
125
- def validate_hex_color(cls, v: str) -> str:
126
- """Validate that the color is a valid hex color code or empty string."""
127
- import re
128
-
129
- if v == "" or re.match(r"^#[0-9A-Fa-f]{6}$", v):
130
- return v
131
- raise ValueError(f"Invalid hex color format: {v}")
201
+ def normalize_material_color(cls, v: Any) -> Any:
202
+ """Normalize the material color, falling back to '' rather than raising."""
203
+ return _normalize_hex_color(v)
132
204
 
133
205
 
134
206
  class FFGcodeToolData(BaseModel):
@@ -140,33 +212,28 @@ class FFGcodeToolData(BaseModel):
140
212
  model_config = ConfigDict(extra="allow", populate_by_name=True)
141
213
 
142
214
  filament_weight: float = Field(
215
+ default=0.0,
143
216
  alias="filamentWeight",
144
- ge=0,
145
217
  description="Calculated filament weight for this tool/material in the print",
146
218
  )
147
219
  material_color: str = Field(
148
- alias="materialColor", description="Material color hex string (e.g., '#FFFF00')"
220
+ default="", alias="materialColor", description="Material color hex string (e.g., '#FFFF00')"
149
221
  )
150
222
  material_name: str = Field(
151
- alias="materialName", description="Name of the material (e.g., 'PLA')"
223
+ default="", alias="materialName", description="Name of the material (e.g., 'PLA')"
152
224
  )
153
225
  slot_id: int = Field(
226
+ default=0,
154
227
  alias="slotId",
155
- ge=0,
156
- le=4,
157
228
  description="Slot ID from the material station, if applicable (0 if not or direct)",
158
229
  )
159
- tool_id: int = Field(alias="toolId", ge=0, le=3, description="Tool ID or extruder number (0-3)")
230
+ tool_id: int = Field(default=0, alias="toolId", description="Tool ID or extruder number (0-3)")
160
231
 
161
- @field_validator("material_color")
232
+ @field_validator("material_color", mode="before")
162
233
  @classmethod
163
- def validate_hex_color(cls, v: str) -> str:
164
- """Validate that the color is a valid hex color code or empty string."""
165
- import re
166
-
167
- if v == "" or re.match(r"^#[0-9A-Fa-f]{6}$", v):
168
- return v
169
- raise ValueError(f"Invalid hex color format: {v}")
234
+ def normalize_material_color(cls, v: Any) -> Any:
235
+ """Normalize the material color, falling back to '' rather than raising."""
236
+ return _normalize_hex_color(v)
170
237
 
171
238
 
172
239
  class FFGcodeFileEntry(BaseModel):
@@ -178,28 +245,27 @@ class FFGcodeFileEntry(BaseModel):
178
245
  # filament weight, and the per-tool material data for EVERY file.
179
246
  model_config = ConfigDict(extra="allow", populate_by_name=True)
180
247
 
248
+ # `gcode_file_name` stays required - it is the one field without which the
249
+ # entry means nothing, so failing here is honest. Everything else is
250
+ # metadata and gets a default.
181
251
  gcode_file_name: str = Field(
182
252
  alias="gcodeFileName", description="The name of the G-code file (e.g., 'FISH_PLA.3mf')"
183
253
  )
184
254
  gcode_tool_cnt: int | None = Field(
185
255
  default=None,
186
- ge=0,
187
- le=4,
188
256
  alias="gcodeToolCnt",
189
257
  description="Number of tools/materials used in this G-code file",
190
258
  )
191
259
  gcode_tool_datas: list[FFGcodeToolData] | None = Field(
192
260
  default=None,
193
- max_length=4,
194
261
  alias="gcodeToolDatas",
195
262
  description="Array of detailed information for each tool/material",
196
263
  )
197
264
  printing_time: int = Field(
198
- alias="printingTime", ge=0, description="Estimated printing time in seconds"
265
+ default=0, alias="printingTime", description="Estimated printing time in seconds"
199
266
  )
200
267
  total_filament_weight: float | None = Field(
201
268
  default=None,
202
- ge=0,
203
269
  alias="totalFilamentWeight",
204
270
  description="Total estimated filament weight for the print",
205
271
  )
@@ -220,33 +286,29 @@ class FFPrinterDetail(BaseModel):
220
286
  model_config = ConfigDict(extra="allow")
221
287
 
222
288
  auto_shutdown: str | None = Field(default=None, alias="autoShutdown")
223
- auto_shutdown_time: int | None = Field(default=None, ge=0, alias="autoShutdownTime")
224
- camera: int | None = Field(default=None, ge=0, alias="camera")
289
+ auto_shutdown_time: int | None = Field(default=None, alias="autoShutdownTime")
290
+ camera: int | None = Field(default=None, alias="camera")
225
291
  camera_stream_url: str | None = Field(default=None, alias="cameraStreamUrl")
226
- chamber_fan_speed: int | None = Field(default=None, ge=0, le=100, alias="chamberFanSpeed")
227
- chamber_target_temp: float | None = Field(
228
- default=None, ge=-50, le=500, alias="chamberTargetTemp"
229
- )
230
- chamber_temp: float | None = Field(default=None, ge=-50, le=500, alias="chamberTemp")
292
+ chamber_fan_speed: int | None = Field(default=None, alias="chamberFanSpeed")
293
+ chamber_target_temp: float | None = Field(default=None, alias="chamberTargetTemp")
294
+ chamber_temp: float | None = Field(default=None, alias="chamberTemp")
231
295
  clear_fan_status: str | None = Field(default=None, alias="clearFanStatus")
232
- cooling_fan_speed: int | None = Field(default=None, ge=0, le=100, alias="coolingFanSpeed")
233
- cooling_fan_left_speed: int | None = Field(
234
- default=None, ge=0, le=100, alias="coolingFanLeftSpeed"
235
- )
296
+ cooling_fan_speed: int | None = Field(default=None, alias="coolingFanSpeed")
297
+ cooling_fan_left_speed: int | None = Field(default=None, alias="coolingFanLeftSpeed")
236
298
  coordinate: list[float] | None = Field(default=None, alias="coordinate")
237
- cumulative_filament: float | None = Field(default=None, ge=0, alias="cumulativeFilament")
238
- cumulative_print_time: int | None = Field(default=None, ge=0, alias="cumulativePrintTime")
239
- current_print_speed: int | None = Field(default=None, ge=0, le=1000, alias="currentPrintSpeed")
299
+ cumulative_filament: float | None = Field(default=None, alias="cumulativeFilament")
300
+ cumulative_print_time: int | None = Field(default=None, alias="cumulativePrintTime")
301
+ current_print_speed: int | None = Field(default=None, alias="currentPrintSpeed")
240
302
  door_status: str | None = Field(default=None, alias="doorStatus")
241
303
  error_code: str | None = Field(default=None, alias="errorCode")
242
- estimated_left_len: float | None = Field(default=None, ge=0, alias="estimatedLeftLen")
243
- estimated_left_weight: float | None = Field(default=None, ge=0, alias="estimatedLeftWeight")
244
- estimated_right_len: float | None = Field(default=None, ge=0, alias="estimatedRightLen")
245
- estimated_right_weight: float | None = Field(default=None, ge=0, alias="estimatedRightWeight")
246
- estimated_time: float | None = Field(default=None, ge=0, alias="estimatedTime")
247
- extrude_ctrl: int | None = Field(default=None, ge=0, alias="extrudeCtrl")
304
+ estimated_left_len: float | None = Field(default=None, alias="estimatedLeftLen")
305
+ estimated_left_weight: float | None = Field(default=None, alias="estimatedLeftWeight")
306
+ estimated_right_len: float | None = Field(default=None, alias="estimatedRightLen")
307
+ estimated_right_weight: float | None = Field(default=None, alias="estimatedRightWeight")
308
+ estimated_time: float | None = Field(default=None, alias="estimatedTime")
309
+ extrude_ctrl: int | None = Field(default=None, alias="extrudeCtrl")
248
310
  external_fan_status: str | None = Field(default=None, alias="externalFanStatus")
249
- fill_amount: float | None = Field(default=None, ge=0, le=100, alias="fillAmount")
311
+ fill_amount: float | None = Field(default=None, alias="fillAmount")
250
312
  firmware_version: str | None = Field(default=None, alias="firmwareVersion")
251
313
  flash_register_code: str | None = Field(default=None, alias="flashRegisterCode")
252
314
  # AD5X-only, and firmware omits what does not apply: the Creator 5 series
@@ -261,48 +323,78 @@ class FFPrinterDetail(BaseModel):
261
323
  internal_fan_status: str | None = Field(default=None, alias="internalFanStatus")
262
324
  ip_addr: str | None = Field(default=None, alias="ipAddr")
263
325
  left_filament_type: str | None = Field(default=None, alias="leftFilamentType")
264
- left_target_temp: float | None = Field(default=None, ge=-50, le=500, alias="leftTargetTemp")
265
- left_temp: float | None = Field(default=None, ge=-50, le=500, alias="leftTemp")
326
+ left_target_temp: float | None = Field(default=None, alias="leftTargetTemp")
327
+ left_temp: float | None = Field(default=None, alias="leftTemp")
266
328
  light_status: str | None = Field(default=None, alias="lightStatus")
267
329
  location: str | None = Field(default=None, alias="location")
268
330
  mac_addr: str | None = Field(default=None, alias="macAddr")
269
331
  measure: str | None = Field(default=None, alias="measure")
270
- move_ctrl: int | None = Field(default=None, ge=0, alias="moveCtrl")
332
+ move_ctrl: int | None = Field(default=None, alias="moveCtrl")
271
333
  name: str | None = Field(default=None, alias="name")
272
- nozzle_cnt: int | None = Field(default=None, ge=1, le=4, alias="nozzleCnt")
334
+ nozzle_cnt: int | None = Field(default=None, alias="nozzleCnt")
273
335
  nozzle_model: str | None = Field(default=None, alias="nozzleModel")
274
- nozzle_style: int | None = Field(default=None, ge=0, alias="nozzleStyle")
336
+ nozzle_style: int | None = Field(default=None, alias="nozzleStyle")
275
337
  # --- Creator 5 series raw fields ---
276
338
  # Immutable factory model name (e.g. "Creator 5 Pro"); unlike `name` this is
277
339
  # not user-editable. May be absent on older firmware.
278
340
  model: str | None = Field(default=None, alias="model")
279
341
  # Per-tool current nozzle temperatures (one entry per nozzle). Multi-nozzle
280
342
  # Creator 5 series report these; single-nozzle models use rightTemp/leftTemp.
281
- nozzle_temps: list[float] | None = Field(default=None, alias="nozzleTemps")
343
+ # Entries are nullable because a sentinel reading is normalized to None by
344
+ # `drop_nozzle_temperature_sentinels` below.
345
+ nozzle_temps: list[float | None] | None = Field(default=None, alias="nozzleTemps")
282
346
  # Per-tool target nozzle temperatures (one entry per nozzle).
283
- nozzle_target_temps: list[float] | None = Field(default=None, alias="nozzleTargetTemps")
347
+ nozzle_target_temps: list[float | None] | None = Field(
348
+ default=None, alias="nozzleTargetTemps"
349
+ )
284
350
  # Lidar / first-layer scanner presence flag (1 = present, 0 = absent).
285
- lidar: int | None = Field(default=None, ge=0, alias="lidar")
286
- pid: int | None = Field(default=None, ge=0, alias="pid")
287
- plat_target_temp: float | None = Field(default=None, ge=-50, le=500, alias="platTargetTemp")
288
- plat_temp: float | None = Field(default=None, ge=-50, le=500, alias="platTemp")
351
+ lidar: int | None = Field(default=None, alias="lidar")
352
+ pid: int | None = Field(default=None, alias="pid")
353
+ plat_target_temp: float | None = Field(default=None, alias="platTargetTemp")
354
+ plat_temp: float | None = Field(default=None, alias="platTemp")
289
355
  polar_register_code: str | None = Field(default=None, alias="polarRegisterCode")
290
- print_duration: int | None = Field(default=None, ge=0, alias="printDuration")
356
+ print_duration: int | None = Field(default=None, alias="printDuration")
291
357
  print_file_name: str | None = Field(default=None, alias="printFileName")
292
358
  print_file_thumb_url: str | None = Field(default=None, alias="printFileThumbUrl")
293
- print_layer: int | None = Field(default=None, ge=0, alias="printLayer")
294
- print_progress: float | None = Field(default=None, ge=0, le=100, alias="printProgress")
295
- print_speed_adjust: int | None = Field(default=None, ge=0, le=1000, alias="printSpeedAdjust")
296
- remaining_disk_space: float | None = Field(default=None, ge=0, alias="remainingDiskSpace")
359
+ print_layer: int | None = Field(default=None, alias="printLayer")
360
+ print_progress: float | None = Field(default=None, alias="printProgress")
361
+ print_speed_adjust: int | None = Field(default=None, alias="printSpeedAdjust")
362
+ remaining_disk_space: float | None = Field(default=None, alias="remainingDiskSpace")
297
363
  right_filament_type: str | None = Field(default=None, alias="rightFilamentType")
298
- right_target_temp: float | None = Field(default=None, ge=-50, le=500, alias="rightTargetTemp")
299
- right_temp: float | None = Field(default=None, ge=-50, le=500, alias="rightTemp")
364
+ right_target_temp: float | None = Field(default=None, alias="rightTargetTemp")
365
+ right_temp: float | None = Field(default=None, alias="rightTemp")
300
366
  status: str | None = Field(default=None, alias="status")
301
- target_print_layer: int | None = Field(default=None, ge=0, alias="targetPrintLayer")
302
- tvoc: float | None = Field(default=None, ge=0, alias="tvoc")
303
- z_axis_compensation: float | None = Field(
304
- default=None, ge=-10, le=10, alias="zAxisCompensation"
367
+ target_print_layer: int | None = Field(default=None, alias="targetPrintLayer")
368
+ tvoc: float | None = Field(default=None, alias="tvoc")
369
+ z_axis_compensation: float | None = Field(default=None, alias="zAxisCompensation")
370
+
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
373
+ # field. Map those to None here so "no sensor" reads as "not reported"
374
+ # everywhere downstream, instead of as a -108 C reading or a hard failure.
375
+ @field_validator(
376
+ "chamber_temp",
377
+ "chamber_target_temp",
378
+ "left_temp",
379
+ "left_target_temp",
380
+ "plat_temp",
381
+ "plat_target_temp",
382
+ "right_temp",
383
+ "right_target_temp",
384
+ mode="before",
305
385
  )
386
+ @classmethod
387
+ def drop_temperature_sentinels(cls, v: Any) -> Any:
388
+ """Replace out-of-band 'no sensor' temperature markers with None."""
389
+ return sanitize_temperature(v)
390
+
391
+ @field_validator("nozzle_temps", "nozzle_target_temps", mode="before")
392
+ @classmethod
393
+ def drop_nozzle_temperature_sentinels(cls, v: Any) -> Any:
394
+ """Apply the same sentinel handling to the per-tool temperature arrays."""
395
+ if not isinstance(v, list):
396
+ return v
397
+ return [sanitize_temperature(entry) for entry in v]
306
398
 
307
399
 
308
400
  class FFMachineInfo(BaseModel):
@@ -393,6 +485,11 @@ class FFMachineInfo(BaseModel):
393
485
  # Only true on models with confirmed hardware (Creator 5 Pro). When false,
394
486
  # `door_open` is cosmetic and should not be surfaced.
395
487
  has_door_sensor: bool = False
488
+ # True only when the printer actually reported a chamber temperature. The
489
+ # heated chamber is a Creator 5 series *option*, not a family trait: units
490
+ # without it report the -108 sentinel, which FFPrinterDetail normalizes to
491
+ # None. Gate chamber entities on this, never on `is_creator5`.
492
+ has_chamber_sensor: bool = False
396
493
 
397
494
  # Current print stats
398
495
  print_duration: int = 0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flashforge-python-api
3
- Version: 1.3.3
3
+ Version: 1.3.4
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,5 +1,6 @@
1
- flashforge/__init__.py,sha256=0PGa08cJ2RjAlIyJqX4MW3w1I-FUIg8ftaJ6I-A5cc0,5336
1
+ flashforge/__init__.py,sha256=pTj91BlyK6rFMy9yD03q8IowcSrUm9ra-b1vMCYEJDo,5472
2
2
  flashforge/client.py,sha256=nDtnv3y9XlEQknIkgIUiuOYAiyTEGbQ7tHBdCs2fg1I,21432
3
+ flashforge/exceptions.py,sha256=rZwBJHyqqtoI6cgd8iGOvPH8EDcytA3lyU3FoVMJgyU,1732
3
4
  flashforge/api/__init__.py,sha256=vQz-DkG6LTH39bI4fyiUc0D9jQzemLh45pORLptSOlg,335
4
5
  flashforge/api/constants/__init__.py,sha256=Q0HL2tqSBYPd4Oz49VHLS3qUvRuv__GCvTGecaLrQ-Y,163
5
6
  flashforge/api/constants/commands.py,sha256=XM2rooBESPDaywlZrfW2ECTaLLFaSKXkJ7FQ-RBmVdY,578
@@ -8,7 +9,7 @@ flashforge/api/controls/__init__.py,sha256=53s-H25Pjwr0kvPk8MZ2Twy8VHGqGcO6Q6uBp
8
9
  flashforge/api/controls/control.py,sha256=OgE7K6NkFLwFN1r_q5g1RPwYC8MCt0f6L5CZxDW44d8,15151
9
10
  flashforge/api/controls/creator5_palette.py,sha256=zdD3PIXx7hCVj2Fmv1CSFOaqogRUPeBaymgY2DFiMxk,8835
10
11
  flashforge/api/controls/files.py,sha256=25yxln9NROas9TtFMZR9e8LtA8ruk8RUu2C81vpNgTk,8043
11
- flashforge/api/controls/info.py,sha256=jvP4mCkywfV79Uz_z1uJ7qWvEx0MwRoB6ziuvwps_YU,18206
12
+ flashforge/api/controls/info.py,sha256=AZ5529HbB1LBfae6voz_4mz30pQd8R8HpcRrPa5qXCw,22451
12
13
  flashforge/api/controls/job_control.py,sha256=NnACm4LPUz4n8lnRiUl33jilWekS1b6HQTbP--nDzZc,33225
13
14
  flashforge/api/controls/temp_control.py,sha256=EOO5TkODirjFcnQ7rZrirxAweHBdVin032AWj-Z8VQs,13040
14
15
  flashforge/api/filament/__init__.py,sha256=isT2dl0hzUS0xMTXMm4Tip0GTG1gCsm8LKWa9xPu4Y8,104
@@ -23,7 +24,7 @@ flashforge/api/network/utils.py,sha256=Q5-Vj_1VN611QV_TGpDzrrZ2X8PqaGT0j00BhSHv5
23
24
  flashforge/discovery/__init__.py,sha256=G0WiP70EhfHdjXR1RNlv6xjhyHOdo-HWyOm9J0ds4SM,828
24
25
  flashforge/discovery/discovery.py,sha256=-hOmVIpn8gu-rPYo8FRBfhKb_tBqwamLfmoPVYaDzLI,27614
25
26
  flashforge/models/__init__.py,sha256=hdcK0E4KeDfv_A8Y2aPOHgHziwTjP1RPb_VP-89dOzQ,1086
26
- flashforge/models/machine_info.py,sha256=UD-BQ-VzceSIoFFrpuFPJLpaisdgxBRxVD8SZ20mF4E,19384
27
+ flashforge/models/machine_info.py,sha256=o0rpT6DPWojg8VMfYleMFIqEaidDvsY6E0MstaVHOT0,24065
27
28
  flashforge/models/responses.py,sha256=10-8yFfZtLuDmH1TK2xVYl-Uf-zT2WEJnpb2H5ZK3ZU,10637
28
29
  flashforge/tcp/__init__.py,sha256=hpnqoWHeRtTwJPzdsVlwGt1njKbAkrgIHKADkLSIRec,1317
29
30
  flashforge/tcp/a3_client.py,sha256=Aqb6MuKkjnNUVdE2LbZKd7bPlFK8XPIm17C_BG_PuNU,15867
@@ -41,8 +42,8 @@ flashforge/tcp/parsers/print_status.py,sha256=C-KzukhK0TR86AP3t3XrMH4dief6pa3I-y
41
42
  flashforge/tcp/parsers/printer_info.py,sha256=CHPs6nJfEByXyG3co7Nb8_ygggxkYuc7A9O9bpQEqXE,5441
42
43
  flashforge/tcp/parsers/temp_info.py,sha256=9wRGUM9cKvDiZD3SIxBx_qVLZbeXoYsoY3-R3vN3H4g,7938
43
44
  flashforge/tcp/parsers/thumbnail_info.py,sha256=1U1S_gcIZ2lDpEA4622ywUew6TVw0BnzjlBAiihQwzk,10478
44
- flashforge_python_api-1.3.3.dist-info/METADATA,sha256=UpYv26NPSHt5nqH57396UOE9s-JcirHPuNsD8e1afBM,4970
45
- flashforge_python_api-1.3.3.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
46
- flashforge_python_api-1.3.3.dist-info/entry_points.txt,sha256=AkOxlsLvQ7cvMLxn7tlzfKp_DCH2hXhbVceHIXxawpU,66
47
- flashforge_python_api-1.3.3.dist-info/licenses/LICENSE,sha256=-cTA-hrmvlb3pqlQrBZQXUnKayhUjsLJMPb7TD91frM,1067
48
- flashforge_python_api-1.3.3.dist-info/RECORD,,
45
+ flashforge_python_api-1.3.4.dist-info/METADATA,sha256=Kdqkl6GJBRr3vRFf6p6Qk-qZ_8TtSUCNS0jdzEIxvHE,4970
46
+ flashforge_python_api-1.3.4.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
47
+ flashforge_python_api-1.3.4.dist-info/entry_points.txt,sha256=AkOxlsLvQ7cvMLxn7tlzfKp_DCH2hXhbVceHIXxawpU,66
48
+ flashforge_python_api-1.3.4.dist-info/licenses/LICENSE,sha256=-cTA-hrmvlb3pqlQrBZQXUnKayhUjsLJMPb7TD91frM,1067
49
+ flashforge_python_api-1.3.4.dist-info/RECORD,,