flashforge-python-api 1.3.2__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.
@@ -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
@@ -4,10 +4,13 @@ FlashForge Python API - Endstop Status Parser
4
4
  Parses endstop and machine status information from M119 command responses.
5
5
  """
6
6
 
7
+ import logging
7
8
  import re
8
9
  from enum import Enum
9
10
  from typing import Optional
10
11
 
12
+ logger = logging.getLogger(__name__)
13
+
11
14
  # Pre-compiled regex to match key:value pairs where value is an integer
12
15
  KV_PATTERN = re.compile(r"([A-Za-z0-9-]+):\s*(\d+)")
13
16
 
@@ -144,7 +147,7 @@ class EndstopStatus:
144
147
  elif "BUSY" in machine_status:
145
148
  self.machine_status = MachineStatus.BUSY
146
149
  else:
147
- print(f"EndstopStatus: Encountered unknown MachineStatus: {machine_status}")
150
+ logger.warning("EndstopStatus: unknown MachineStatus: %s", machine_status)
148
151
  self.machine_status = MachineStatus.DEFAULT
149
152
  elif line.startswith("MoveMode:"):
150
153
  move_mode = line.replace("MoveMode:", "", 1).strip().upper()
@@ -159,7 +162,7 @@ class EndstopStatus:
159
162
  elif "HOMING" in move_mode:
160
163
  self.move_mode = MoveMode.HOMING
161
164
  else:
162
- print(f"EndstopStatus: Encountered unknown MoveMode: {move_mode}")
165
+ logger.warning("EndstopStatus: unknown MoveMode: %s", move_mode)
163
166
  self.move_mode = MoveMode.DEFAULT
164
167
  elif line.startswith("Status "):
165
168
  self.status = Status(line)
@@ -184,9 +187,8 @@ class EndstopStatus:
184
187
  return self
185
188
 
186
189
  except Exception as e:
187
- print("Unable to create EndstopStatus instance from replay")
188
- print(f"Replay: {replay}")
189
- print(f"Error: {e}")
190
+ logger.warning("Unable to create an EndstopStatus from the reply: %s", e)
191
+ logger.debug("Reply that failed to parse: %s", replay)
190
192
  return None
191
193
 
192
194
  def is_print_complete(self) -> bool:
@@ -4,8 +4,11 @@ FlashForge Python API - Print Status Parser
4
4
  Parses print progress information from M27 command responses.
5
5
  """
6
6
 
7
+ import logging
7
8
  from typing import Optional
8
9
 
10
+ logger = logging.getLogger(__name__)
11
+
9
12
 
10
13
  class PrintStatus:
11
14
  """
@@ -69,13 +72,13 @@ class PrintStatus:
69
72
  return None
70
73
 
71
74
  if not self.sd_current or not self.sd_total:
72
- print("PrintStatus: Invalid SD progress format")
75
+ logger.warning("PrintStatus: invalid SD progress format.")
73
76
  return None
74
77
 
75
78
  return self
76
79
 
77
80
  except Exception as e:
78
- print(f"Error parsing print status: {e}")
81
+ logger.warning("Error parsing the print status: %s", e)
79
82
  return None
80
83
 
81
84
  def get_print_percent(self) -> float: