flashforge-python-api 1.3.1__py3-none-any.whl → 1.3.3__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/client.py CHANGED
@@ -6,9 +6,11 @@ communication layers for controlling FlashForge 3D printers.
6
6
  """
7
7
 
8
8
  import asyncio
9
+ import logging
9
10
  from dataclasses import dataclass
10
11
 
11
12
  import aiohttp
13
+ from pydantic import ValidationError
12
14
 
13
15
  from .api.constants.endpoints import CAMERA_STREAM_PORT, Endpoints
14
16
  from .api.controls import Control, Files, Info, JobControl, TempControl
@@ -19,6 +21,8 @@ from .tcp import FlashForgeClient as TcpClient
19
21
  from .tcp import FlashForgeTcpClientOptions, PrinterInfo
20
22
  from .tcp.parsers.temp_info import TempInfo
21
23
 
24
+ logger = logging.getLogger(__name__)
25
+
22
26
 
23
27
  @dataclass(slots=True)
24
28
  class FiveMClientConnectionOptions:
@@ -160,7 +164,7 @@ class FlashForgeClient:
160
164
  True if TCP operations are permitted, False otherwise
161
165
  """
162
166
  if self._http_only:
163
- print(f"{op}() unavailable: printer has no TCP control channel (HTTP-only).")
167
+ logger.debug("%s() unavailable: printer has no TCP control channel (HTTP-only).", op)
164
168
  return False
165
169
  return True
166
170
 
@@ -208,7 +212,7 @@ class FlashForgeClient:
208
212
  connected = await self.verify_connection()
209
213
  if connected:
210
214
  return True
211
- print("Failed to connect to printer")
215
+ logger.warning("Failed to connect to the printer.")
212
216
  return False
213
217
 
214
218
  @property
@@ -256,7 +260,7 @@ class FlashForgeClient:
256
260
  """
257
261
  if await self.send_product_command():
258
262
  return await self.tcp_client.init_control()
259
- print("New API control failed!")
263
+ logger.warning("New API control failed; the product command was rejected.")
260
264
  return False
261
265
 
262
266
  async def dispose(self) -> None:
@@ -392,13 +396,13 @@ class FlashForgeClient:
392
396
  # Get HTTP API response
393
397
  response = await self.info.get_detail_response()
394
398
  if not response or not NetworkUtils.is_ok(response):
395
- print("Failed to get valid response from printer API")
399
+ logger.warning("Failed to get a valid response from the printer API.")
396
400
  return False
397
401
 
398
402
  # Parse machine info from detail response
399
403
  machine_info = MachineInfoParser.from_detail(response.detail)
400
404
  if not machine_info:
401
- print("Failed to parse machine info from detail response")
405
+ logger.warning("Failed to parse machine info from the /detail response.")
402
406
  return False
403
407
 
404
408
  # Detect http_only from the parsed model BEFORE touching TCP. The
@@ -422,16 +426,16 @@ class FlashForgeClient:
422
426
  ):
423
427
  self.is_pro = True
424
428
  else:
425
- print(
426
- "Warning: Unable to get PrinterInfo from TCP API, "
427
- "some features might not work"
429
+ logger.warning(
430
+ "Unable to get PrinterInfo from the TCP API; some features might "
431
+ "not work."
428
432
  )
429
433
 
430
434
  # Cache the details
431
435
  return self.cache_details(machine_info)
432
436
 
433
437
  except Exception as error:
434
- print(f"Error in verify_connection: {error}")
438
+ logger.warning("Error in verify_connection: %s", error)
435
439
  return False
436
440
 
437
441
  async def send_product_command(self) -> bool:
@@ -476,8 +480,21 @@ class FlashForgeClient:
476
480
  self._apply_feature_overrides()
477
481
  return True
478
482
 
483
+ except ValidationError as error:
484
+ # Distinct from a rejected check code, and it must not read as one:
485
+ # the printer answered 200 with code 0, we simply could not parse
486
+ # what it said. Callers treat a False return as bad credentials
487
+ # (ff-5mp-hass surfaces it as "check code incorrect"), so the log
488
+ # line is the only place the real cause can surface.
489
+ logger.warning(
490
+ "Could not parse the /product response; reporting the printer as "
491
+ "unavailable rather than the credentials as rejected. This usually "
492
+ "means the firmware added a field this library has not seen. %s",
493
+ error,
494
+ )
495
+ return False
479
496
  except Exception as error:
480
- print(f"Error in send_product_command: {error}")
497
+ logger.warning("Error in send_product_command: %s", error)
481
498
  return False
482
499
  finally:
483
500
  self._http_client_busy = False
@@ -303,7 +303,9 @@ class PrinterDiscovery:
303
303
  try:
304
304
  transport, _ = await self._create_endpoint(message_queue, error_queue)
305
305
  self._send_discovery_packets(transport, config)
306
- discovered_printers = await self._receive_responses(message_queue, error_queue, config)
306
+ discovered_printers = await self._receive_responses(
307
+ message_queue, error_queue, config
308
+ )
307
309
 
308
310
  for printer in discovered_printers:
309
311
  key = f"{printer.ip_address}:{printer.command_port}"
@@ -454,7 +456,9 @@ class PrinterDiscovery:
454
456
  status_code = int.from_bytes(buffer[0x8A:0x8C], byteorder="big")
455
457
  product_type = int.from_bytes(buffer[0x8C:0x8E], byteorder="big")
456
458
  event_port = int.from_bytes(buffer[0x8E:0x90], byteorder="big")
457
- serial_number = buffer[0x92 : 0x92 + 128].decode("utf-8", errors="ignore").split("\x00", 1)[0]
459
+ serial_number = (
460
+ buffer[0x92 : 0x92 + 128].decode("utf-8", errors="ignore").split("\x00", 1)[0]
461
+ )
458
462
  model = self.detect_modern_model(name, product_type, product_id)
459
463
 
460
464
  return DiscoveredPrinter(
@@ -586,9 +590,7 @@ class PrinterDiscovery:
586
590
  continue
587
591
 
588
592
  mask_int = (0xFFFFFFFF << (32 - prefix)) & 0xFFFFFFFF
589
- netmask = ".".join(
590
- str((mask_int >> (8 * (3 - i))) & 0xFF) for i in range(4)
591
- )
593
+ netmask = ".".join(str((mask_int >> (8 * (3 - i))) & 0xFF) for i in range(4))
592
594
 
593
595
  broadcast = self.calculate_broadcast_address(ip_addr, netmask)
594
596
  if broadcast and broadcast not in broadcast_addresses:
@@ -695,7 +697,9 @@ class FlashForgePrinterDiscovery:
695
697
  return None
696
698
 
697
699
  if LEGACY_PROTOCOL_SIZE <= len(response) < MODERN_PROTOCOL_SIZE and len(response) >= 0x92:
698
- legacy_name = response[0:32].decode("utf-8", errors="ignore").split("\x00", 1)[0].strip()
700
+ legacy_name = (
701
+ response[0:32].decode("utf-8", errors="ignore").split("\x00", 1)[0].strip()
702
+ )
699
703
  legacy_serial = (
700
704
  response[0x92 : 0x92 + 32]
701
705
  .decode("utf-8", errors="ignore")
@@ -29,6 +29,10 @@ class MachineState(Enum):
29
29
  class Temperature(BaseModel):
30
30
  """Represents a pair of current and target temperatures for a component like an extruder or print bed."""
31
31
 
32
+ # `extra="forbid"` deliberately: this model is never parsed from a printer
33
+ # payload, only constructed by MachineInfoParser.from_detail. Forbidding
34
+ # extras there catches a typo'd keyword argument in our own parser, which
35
+ # `extra="allow"` would silently absorb.
32
36
  model_config = ConfigDict(extra="forbid")
33
37
 
34
38
  current: float = Field(
@@ -42,7 +46,12 @@ class Temperature(BaseModel):
42
46
  class SlotInfo(BaseModel):
43
47
  """Information about a single slot in the material station."""
44
48
 
45
- model_config = ConfigDict(extra="forbid", populate_by_name=True)
49
+ # `extra="allow"`: inbound, nested under FFPrinterDetail.matl_station_info.
50
+ # FFPrinterDetail allowing extras is not enough on its own - a new field on
51
+ # a *child* fails validation for the whole /detail response, and
52
+ # `Info.get_detail_response` swallows that and returns None, which the HA
53
+ # integration reports as "could not retrieve printer information".
54
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
46
55
 
47
56
  has_filament: bool = Field(
48
57
  alias="hasFilament", description="Indicates if filament is present in this slot"
@@ -69,7 +78,8 @@ class SlotInfo(BaseModel):
69
78
  class MatlStationInfo(BaseModel):
70
79
  """Detailed information about the material station."""
71
80
 
72
- model_config = ConfigDict(extra="forbid", populate_by_name=True)
81
+ # `extra="allow"`: inbound, see SlotInfo.
82
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
73
83
 
74
84
  current_load_slot: int = Field(
75
85
  alias="currentLoadSlot", ge=0, le=4, description="Currently loading slot ID (0 if none)"
@@ -98,7 +108,8 @@ class MatlStationInfo(BaseModel):
98
108
  class IndepMatlInfo(BaseModel):
99
109
  """Information related to independent material loading, often used when a single extruder printer has a material station."""
100
110
 
101
- model_config = ConfigDict(extra="forbid", populate_by_name=True)
111
+ # `extra="allow"`: inbound, see SlotInfo.
112
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
102
113
 
103
114
  material_color: str = Field(alias="materialColor", description="Color of the material")
104
115
  material_name: str = Field(
@@ -123,7 +134,10 @@ class IndepMatlInfo(BaseModel):
123
134
  class FFGcodeToolData(BaseModel):
124
135
  """Represents data for a single tool/material used in a G-code file, typically part of a multi-material print."""
125
136
 
126
- model_config = ConfigDict(extra="forbid", populate_by_name=True)
137
+ # `extra="allow"`: inbound, nested under FFGcodeFileEntry - which already
138
+ # allows extras, but a child that forbids them reintroduces the same
139
+ # names-only fallback it was flipped to avoid.
140
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
127
141
 
128
142
  filament_weight: float = Field(
129
143
  alias="filamentWeight",
@@ -158,7 +172,11 @@ class FFGcodeToolData(BaseModel):
158
172
  class FFGcodeFileEntry(BaseModel):
159
173
  """Represents a single G-code file entry as returned by the /gcodeList endpoint, especially for printers like AD5X that provide detailed material info."""
160
174
 
161
- model_config = ConfigDict(extra="forbid", populate_by_name=True)
175
+ # `extra="allow"`, matching FFPrinterDetail: a firmware update that adds one
176
+ # field must not fail validation, because Files.get_recent_file_list falls
177
+ # back to a names-only list when it does - silently dropping print time,
178
+ # filament weight, and the per-tool material data for EVERY file.
179
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
162
180
 
163
181
  gcode_file_name: str = Field(
164
182
  alias="gcodeFileName", description="The name of the G-code file (e.g., 'FISH_PLA.3mf')"
@@ -231,6 +249,10 @@ class FFPrinterDetail(BaseModel):
231
249
  fill_amount: float | None = Field(default=None, ge=0, le=100, alias="fillAmount")
232
250
  firmware_version: str | None = Field(default=None, alias="firmwareVersion")
233
251
  flash_register_code: str | None = Field(default=None, alias="flashRegisterCode")
252
+ # AD5X-only, and firmware omits what does not apply: the Creator 5 series
253
+ # leaves this out of /detail even with four loaded slots, so None means "not
254
+ # reported", NOT "no station". Never gate a feature on it - read the derived
255
+ # FFMachineInfo.has_matl_station, or check matl_station_info yourself.
234
256
  has_matl_station: bool | None = Field(default=None, alias="hasMatlStation")
235
257
  matl_station_info: MatlStationInfo | None = Field(default=None, alias="matlStationInfo")
236
258
  indep_matl_info: IndepMatlInfo | None = Field(default=None, alias="indepMatlInfo")
@@ -289,6 +311,10 @@ class FFMachineInfo(BaseModel):
289
311
  This interface is populated by transforming data from FFPrinterDetail.
290
312
  """
291
313
 
314
+ # `extra="forbid"` deliberately: see Temperature. Built only at
315
+ # `MachineInfoParser.from_detail`, from ~50 keyword arguments, never from
316
+ # raw printer JSON - so forbidding extras is a typo check on our own code,
317
+ # not a firmware-compatibility risk.
292
318
  model_config = ConfigDict(extra="forbid")
293
319
 
294
320
  # Auto-shutdown settings
@@ -395,8 +421,15 @@ class FFMachineInfo(BaseModel):
395
421
  formatted_run_time: str = "00:00"
396
422
  formatted_total_run_time: str = "0h:0m"
397
423
 
398
- # AD5X Material Station
399
- has_matl_station: bool | None = None
424
+ # Material Station (AD5X / Creator 5 series)
425
+ #
426
+ # A capability, not a passthrough: MachineInfoParser DERIVES this from the
427
+ # station data rather than copying the raw `hasMatlStation` field, which the
428
+ # Creator 5 series never reports even with a station attached. It is a plain
429
+ # bool on purpose - there is no "unknown" state to represent, and offering
430
+ # one is what let an unreported flag read as absent hardware. For the
431
+ # untouched firmware value, read FFPrinterDetail.has_matl_station.
432
+ has_matl_station: bool = False
400
433
  matl_station_info: MatlStationInfo | None = None
401
434
  indep_matl_info: IndepMatlInfo | None = None
402
435
 
@@ -10,7 +10,12 @@ from .machine_info import FFGcodeFileEntry, FFPrinterDetail
10
10
  class GenericResponse(BaseModel):
11
11
  """Represents a generic response from the printer's API."""
12
12
 
13
- model_config = ConfigDict(extra="forbid")
13
+ # `extra="allow"` on every inbound model: this is the envelope each printer
14
+ # response inherits, so a firmware update that adds one top-level key must
15
+ # not fail validation here. Callers cannot tell a parse failure from a real
16
+ # error - `send_product_command` returns a bare False either way, which the
17
+ # HA integration reports to the user as "check code incorrect".
18
+ model_config = ConfigDict(extra="allow")
14
19
 
15
20
  code: int
16
21
  message: str = ""
@@ -19,8 +24,6 @@ class GenericResponse(BaseModel):
19
24
  class DetailResponse(GenericResponse):
20
25
  """Represents the structure of the response from the printer's detail endpoint."""
21
26
 
22
- model_config = ConfigDict(extra="forbid")
23
-
24
27
  detail: FFPrinterDetail
25
28
 
26
29
 
@@ -36,7 +39,10 @@ class Product(BaseModel):
36
39
  Field names match the actual camelCase format returned by the printer.
37
40
  """
38
41
 
39
- model_config = ConfigDict(extra="forbid")
42
+ # `extra="allow"`: an unrecognized control-state flag must not fail
43
+ # validation. `send_product_command` has no fallback - it returns False,
44
+ # which is indistinguishable from the printer rejecting the credentials.
45
+ model_config = ConfigDict(extra="allow")
40
46
 
41
47
  chamberTempCtrlState: int # noqa: N815 - field must match API camelCase response format
42
48
  externalFanCtrlState: int # noqa: N815 - field must match API camelCase response format
@@ -55,8 +61,6 @@ class ProductResponse(GenericResponse):
55
61
  and a nested `product` object containing specific control states.
56
62
  """
57
63
 
58
- model_config = ConfigDict(extra="forbid")
59
-
60
64
  product: Product
61
65
 
62
66
 
@@ -96,6 +100,10 @@ class FilamentArgs(BaseModel):
96
100
  class AD5XMaterialMapping(BaseModel):
97
101
  """Represents a material mapping for AD5X multi-color printing. Maps a tool (extruder) to a specific material station slot."""
98
102
 
103
+ # `extra="forbid"` deliberately, as on every outbound param model below:
104
+ # these are request bodies this library constructs, so no firmware update
105
+ # can break them, and forbidding extras turns a caller's typo'd keyword
106
+ # into an error instead of a field the printer silently ignores.
99
107
  model_config = ConfigDict(extra="forbid", populate_by_name=True)
100
108
 
101
109
  tool_id: int = Field(ge=0, le=3, description="Tool ID (0-based: 0, 1, 2, 3)")
@@ -181,9 +189,7 @@ class Creator5JobParams(BaseModel):
181
189
  leveling_before_print: bool = Field(
182
190
  description="Whether to perform bed leveling before printing"
183
191
  )
184
- flow_calibration: bool = Field(
185
- default=False, description="Whether to enable flow calibration"
186
- )
192
+ flow_calibration: bool = Field(default=False, description="Whether to enable flow calibration")
187
193
  time_lapse_video: bool = Field(
188
194
  default=False, description="Whether to enable time lapse video recording"
189
195
  )
@@ -209,15 +215,11 @@ class Creator5UploadParams(BaseModel):
209
215
  leveling_before_print: bool = Field(
210
216
  description="Whether to perform bed leveling before printing"
211
217
  )
212
- flow_calibration: bool = Field(
213
- default=False, description="Whether to enable flow calibration"
214
- )
218
+ flow_calibration: bool = Field(default=False, description="Whether to enable flow calibration")
215
219
  time_lapse_video: bool = Field(
216
220
  default=False, description="Whether to enable time lapse video recording"
217
221
  )
218
- use_matl_station: bool = Field(
219
- description="Whether this is a multi-tool material-station job"
220
- )
222
+ use_matl_station: bool = Field(description="Whether this is a multi-tool material-station job")
221
223
  gcode_tool_cnt: int = Field(
222
224
  ge=1, le=4, description="Number of tools in the G-code (1-4 for the C5)"
223
225
  )
@@ -226,7 +228,9 @@ class Creator5UploadParams(BaseModel):
226
228
  class GCodeListResponse(GenericResponse):
227
229
  """Represents the response structure for a G-code file list request."""
228
230
 
229
- model_config = ConfigDict(extra="forbid", populate_by_name=True)
231
+ # `extra="allow"`: see FFGcodeFileEntry. An unknown field at either level
232
+ # costs the caller all per-file metadata, so neither level may forbid one.
233
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
230
234
 
231
235
  gcode_list: list[str] | list[FFGcodeFileEntry] | None = Field(default=None, alias="gcodeList")
232
236
  gcode_list_detail: list[FFGcodeFileEntry] | None = Field(default=None, alias="gcodeListDetail")
@@ -235,7 +239,8 @@ class GCodeListResponse(GenericResponse):
235
239
  class ThumbnailResponse(GenericResponse):
236
240
  """Represents the response structure for a G-code thumbnail request."""
237
241
 
238
- model_config = ConfigDict(extra="forbid", populate_by_name=True)
242
+ # `extra="allow"`: inbound, see GenericResponse.
243
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
239
244
 
240
245
  image_data: str = Field(
241
246
  alias="imageData", description="The thumbnail image data encoded as a base64 string"
@@ -84,7 +84,11 @@ class FlashForgeA3Client(FlashForgeTcpClient):
84
84
  bare_cmd = self._strip_protocol_prefix(cmd)
85
85
  if bare_cmd.startswith("M661"):
86
86
  return 500
87
- if bare_cmd.startswith("M115") or bare_cmd.startswith("M119") or bare_cmd.startswith("M650"):
87
+ if (
88
+ bare_cmd.startswith("M115")
89
+ or bare_cmd.startswith("M119")
90
+ or bare_cmd.startswith("M650")
91
+ ):
88
92
  return 250
89
93
  return 200
90
94
 
@@ -102,7 +106,7 @@ class FlashForgeA3Client(FlashForgeTcpClient):
102
106
  if "Error: File not exists" in text_prefix:
103
107
  return True
104
108
 
105
- magic_offset = buffer.find(b"\xA2\xA2\x2A\x2A")
109
+ magic_offset = buffer.find(b"\xa2\xa2\x2a\x2a")
106
110
  if magic_offset == -1 or len(buffer) < magic_offset + 8:
107
111
  return False
108
112
 
@@ -163,7 +167,9 @@ class FlashForgeA3Client(FlashForgeTcpClient):
163
167
  elif line.startswith("Mac Address:"):
164
168
  info.mac_address = line.replace("Mac Address:", "", 1).strip()
165
169
  else:
166
- volume_match = re.search(r"X:\s*(\d+)\s+Y:\s*(\d+)\s+Z:\s*(\d+)", line, re.IGNORECASE)
170
+ volume_match = re.search(
171
+ r"X:\s*(\d+)\s+Y:\s*(\d+)\s+Z:\s*(\d+)", line, re.IGNORECASE
172
+ )
167
173
  if volume_match:
168
174
  info.build_volume = A3BuildVolume(
169
175
  x=int(volume_match.group(1)),
@@ -331,7 +337,11 @@ class FlashForgeA3Client(FlashForgeTcpClient):
331
337
 
332
338
  files: list[A3FileEntry] = []
333
339
  count_index = next(
334
- (index for index, line in enumerate(lines) if re.search(r"info_list\.size:\s*\d+", line)),
340
+ (
341
+ index
342
+ for index, line in enumerate(lines)
343
+ if re.search(r"info_list\.size:\s*\d+", line)
344
+ ),
335
345
  -1,
336
346
  )
337
347
  if count_index == -1:
@@ -354,11 +364,13 @@ class FlashForgeA3Client(FlashForgeTcpClient):
354
364
  if "Error: File not exists" in error_text:
355
365
  return None
356
366
 
357
- magic_offset = buffer.find(b"\xA2\xA2\x2A\x2A")
367
+ magic_offset = buffer.find(b"\xa2\xa2\x2a\x2a")
358
368
  if magic_offset == -1 or len(buffer) < magic_offset + 8:
359
369
  return None
360
370
 
361
- payload_length = int.from_bytes(buffer[magic_offset + 4 : magic_offset + 8], byteorder="big")
371
+ payload_length = int.from_bytes(
372
+ buffer[magic_offset + 4 : magic_offset + 8], byteorder="big"
373
+ )
362
374
  payload_end = magic_offset + 8 + payload_length
363
375
  if len(buffer) < payload_end:
364
376
  return None
@@ -378,7 +390,11 @@ class FlashForgeA3Client(FlashForgeTcpClient):
378
390
  return normalized.rstrip()
379
391
 
380
392
  def _get_normalized_lines(self, response: str) -> list[str]:
381
- return [line.strip() for line in self._normalize_a3_text_response(response).split("\n") if line.strip()]
393
+ return [
394
+ line.strip()
395
+ for line in self._normalize_a3_text_response(response).split("\n")
396
+ if line.strip()
397
+ ]
382
398
 
383
399
  def _map_controller_command(self, cmd: str) -> str:
384
400
  if cmd == "~M146 r255 g255 b255 F0":
@@ -399,7 +415,10 @@ class FlashForgeA3Client(FlashForgeTcpClient):
399
415
  if bare_cmd.startswith("M23"):
400
416
  return "File opened" in normalized or "ok" in normalized
401
417
  if bare_cmd.startswith("M112"):
402
- return bool(re.search(r"Emergency Stop", normalized, re.IGNORECASE)) or "Received." in normalized
418
+ return (
419
+ bool(re.search(r"Emergency Stop", normalized, re.IGNORECASE))
420
+ or "Received." in normalized
421
+ )
403
422
  if "ok" in normalized or "Received." in normalized:
404
423
  return True
405
424
  return normalized.startswith(bare_cmd)
@@ -274,7 +274,11 @@ class FlashForgeA4Client(FlashForgeTcpClient):
274
274
  return normalized
275
275
 
276
276
  def _get_normalized_lines(self, response: str) -> list[str]:
277
- return [line.strip() for line in self._normalize_a4_text_response(response).split("\n") if line.strip()]
277
+ return [
278
+ line.strip()
279
+ for line in self._normalize_a4_text_response(response).split("\n")
280
+ if line.strip()
281
+ ]
278
282
 
279
283
  def _detect_variant(self, machine_type: str) -> A4PrinterVariant:
280
284
  normalized_type = machine_type.upper()
@@ -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
 
@@ -118,7 +121,9 @@ class EndstopStatus:
118
121
  return None
119
122
 
120
123
  try:
121
- lines = [line.strip() for line in replay.replace("\r", "\n").split("\n") if line.strip()]
124
+ lines = [
125
+ line.strip() for line in replay.replace("\r", "\n").split("\n") if line.strip()
126
+ ]
122
127
  if not lines:
123
128
  return None
124
129
 
@@ -142,7 +147,7 @@ class EndstopStatus:
142
147
  elif "BUSY" in machine_status:
143
148
  self.machine_status = MachineStatus.BUSY
144
149
  else:
145
- print(f"EndstopStatus: Encountered unknown MachineStatus: {machine_status}")
150
+ logger.warning("EndstopStatus: unknown MachineStatus: %s", machine_status)
146
151
  self.machine_status = MachineStatus.DEFAULT
147
152
  elif line.startswith("MoveMode:"):
148
153
  move_mode = line.replace("MoveMode:", "", 1).strip().upper()
@@ -157,7 +162,7 @@ class EndstopStatus:
157
162
  elif "HOMING" in move_mode:
158
163
  self.move_mode = MoveMode.HOMING
159
164
  else:
160
- print(f"EndstopStatus: Encountered unknown MoveMode: {move_mode}")
165
+ logger.warning("EndstopStatus: unknown MoveMode: %s", move_mode)
161
166
  self.move_mode = MoveMode.DEFAULT
162
167
  elif line.startswith("Status "):
163
168
  self.status = Status(line)
@@ -182,9 +187,8 @@ class EndstopStatus:
182
187
  return self
183
188
 
184
189
  except Exception as e:
185
- print("Unable to create EndstopStatus instance from replay")
186
- print(f"Replay: {replay}")
187
- 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)
188
192
  return None
189
193
 
190
194
  def is_print_complete(self) -> bool:
@@ -47,7 +47,9 @@ class LocationInfo:
47
47
  The populated LocationInfo instance, or None if parsing fails
48
48
  """
49
49
  try:
50
- lines = [line.strip() for line in replay.replace("\r", "\n").split("\n") if line.strip()]
50
+ lines = [
51
+ line.strip() for line in replay.replace("\r", "\n").split("\n") if line.strip()
52
+ ]
51
53
  coordinate_line = next(
52
54
  (line for line in lines if "X:" in line and "Y:" in line and "Z:" in line),
53
55
  "",
@@ -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
  """
@@ -48,7 +51,9 @@ class PrintStatus:
48
51
  self.sd_total = ""
49
52
  self.layer_current = ""
50
53
  self.layer_total = ""
51
- lines = [line.strip() for line in replay.replace("\r", "\n").split("\n") if line.strip()]
54
+ lines = [
55
+ line.strip() for line in replay.replace("\r", "\n").split("\n") if line.strip()
56
+ ]
52
57
 
53
58
  for line in lines:
54
59
  if "SD printing byte " in line:
@@ -67,13 +72,13 @@ class PrintStatus:
67
72
  return None
68
73
 
69
74
  if not self.sd_current or not self.sd_total:
70
- print("PrintStatus: Invalid SD progress format")
75
+ logger.warning("PrintStatus: invalid SD progress format.")
71
76
  return None
72
77
 
73
78
  return self
74
79
 
75
80
  except Exception as e:
76
- print(f"Error parsing print status: {e}")
81
+ logger.warning("Error parsing the print status: %s", e)
77
82
  return None
78
83
 
79
84
  def get_print_percent(self) -> float:
@@ -70,7 +70,9 @@ class PrinterInfo:
70
70
  return None
71
71
 
72
72
  try:
73
- lines = [line.strip() for line in replay.replace("\r", "\n").split("\n") if line.strip()]
73
+ lines = [
74
+ line.strip() for line in replay.replace("\r", "\n").split("\n") if line.strip()
75
+ ]
74
76
 
75
77
  for line in lines:
76
78
  if line.startswith("Machine Type:"):
@@ -127,7 +127,9 @@ class TempInfo:
127
127
  return None
128
128
 
129
129
  try:
130
- lines = [line.strip() for line in replay.replace("\r", "\n").split("\n") if line.strip()]
130
+ lines = [
131
+ line.strip() for line in replay.replace("\r", "\n").split("\n") if line.strip()
132
+ ]
131
133
  if not lines:
132
134
  logger.error("TempInfo replay has invalid data: %s", replay)
133
135
  return None