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/api/controls/control.py +16 -9
- flashforge/api/controls/creator5_palette.py +9 -6
- flashforge/api/controls/files.py +26 -8
- flashforge/api/controls/info.py +33 -9
- flashforge/api/controls/job_control.py +109 -71
- flashforge/api/controls/temp_control.py +14 -7
- flashforge/api/misc/redaction.py +88 -0
- flashforge/client.py +27 -10
- flashforge/discovery/discovery.py +10 -6
- flashforge/models/machine_info.py +40 -7
- flashforge/models/responses.py +22 -17
- flashforge/tcp/a3_client.py +27 -8
- flashforge/tcp/a4_client.py +5 -1
- flashforge/tcp/parsers/endstop_status.py +10 -6
- flashforge/tcp/parsers/location_info.py +3 -1
- flashforge/tcp/parsers/print_status.py +8 -3
- flashforge/tcp/parsers/printer_info.py +3 -1
- flashforge/tcp/parsers/temp_info.py +3 -1
- flashforge/tcp/parsers/thumbnail_info.py +19 -12
- flashforge/tcp/tcp_client.py +6 -5
- {flashforge_python_api-1.3.1.dist-info → flashforge_python_api-1.3.3.dist-info}/METADATA +1 -1
- flashforge_python_api-1.3.3.dist-info/RECORD +48 -0
- {flashforge_python_api-1.3.1.dist-info → flashforge_python_api-1.3.3.dist-info}/WHEEL +1 -1
- flashforge_python_api-1.3.1.dist-info/RECORD +0 -47
- {flashforge_python_api-1.3.1.dist-info → flashforge_python_api-1.3.3.dist-info}/entry_points.txt +0 -0
- {flashforge_python_api-1.3.1.dist-info → flashforge_python_api-1.3.3.dist-info}/licenses/LICENSE +0 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
426
|
-
"
|
|
427
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
399
|
-
|
|
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
|
|
flashforge/models/responses.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
flashforge/tcp/a3_client.py
CHANGED
|
@@ -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
|
|
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"\
|
|
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(
|
|
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
|
-
(
|
|
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"\
|
|
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(
|
|
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 [
|
|
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
|
|
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)
|
flashforge/tcp/a4_client.py
CHANGED
|
@@ -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 [
|
|
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 = [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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 = [
|
|
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 = [
|
|
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
|
-
|
|
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
|
-
|
|
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 = [
|
|
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 = [
|
|
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
|