flashforge-python-api 1.0.2__py3-none-any.whl → 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flashforge/__init__.py +70 -29
- flashforge/api/__init__.py +1 -0
- flashforge/api/constants/__init__.py +1 -0
- flashforge/api/constants/commands.py +1 -0
- flashforge/api/constants/endpoints.py +7 -0
- flashforge/api/controls/__init__.py +1 -0
- flashforge/api/controls/control.py +64 -61
- flashforge/api/controls/files.py +28 -26
- flashforge/api/controls/info.py +84 -85
- flashforge/api/controls/job_control.py +120 -82
- flashforge/api/controls/temp_control.py +14 -11
- flashforge/api/misc/__init__.py +1 -1
- flashforge/api/network/__init__.py +2 -1
- flashforge/api/network/utils.py +7 -8
- flashforge/client.py +185 -64
- flashforge/discovery/__init__.py +30 -3
- flashforge/discovery/discovery.py +641 -308
- flashforge/models/__init__.py +11 -10
- flashforge/models/machine_info.py +225 -100
- flashforge/models/responses.py +103 -43
- flashforge/tcp/__init__.py +30 -17
- flashforge/tcp/a3_client.py +408 -0
- flashforge/tcp/a4_client.py +300 -0
- flashforge/tcp/ff_client.py +93 -90
- flashforge/tcp/gcode/__init__.py +4 -2
- flashforge/tcp/gcode/a3_gcode_controller.py +109 -0
- flashforge/tcp/gcode/gcode_controller.py +48 -36
- flashforge/tcp/gcode/gcodes.py +7 -1
- flashforge/tcp/parsers/__init__.py +11 -11
- flashforge/tcp/parsers/endstop_status.py +103 -132
- flashforge/tcp/parsers/location_info.py +26 -13
- flashforge/tcp/parsers/print_status.py +49 -56
- flashforge/tcp/parsers/printer_info.py +38 -48
- flashforge/tcp/parsers/temp_info.py +46 -47
- flashforge/tcp/parsers/thumbnail_info.py +65 -40
- flashforge/tcp/tcp_client.py +296 -293
- flashforge_python_api-1.2.0.dist-info/METADATA +133 -0
- flashforge_python_api-1.2.0.dist-info/RECORD +46 -0
- {flashforge_python_api-1.0.2.dist-info → flashforge_python_api-1.2.0.dist-info}/WHEEL +1 -1
- flashforge_python_api-1.0.2.dist-info/METADATA +0 -284
- flashforge_python_api-1.0.2.dist-info/RECORD +0 -43
- {flashforge_python_api-1.0.2.dist-info → flashforge_python_api-1.2.0.dist-info}/entry_points.txt +0 -0
- {flashforge_python_api-1.0.2.dist-info → flashforge_python_api-1.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Adventurer 4 TCP client and typed helpers.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import logging
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from .gcode import GCodeController, GCodes
|
|
13
|
+
from .parsers import EndstopStatus, LocationInfo, PrintStatus, TempInfo, ThumbnailInfo
|
|
14
|
+
from .tcp_client import FlashForgeTcpClient, FlashForgeTcpClientOptions
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
A4PrinterVariant = Literal["lite", "pro", "unknown"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class A4BuildVolume:
|
|
23
|
+
"""Build volume metadata reported by Adventurer 4 M115 replies."""
|
|
24
|
+
|
|
25
|
+
x: int
|
|
26
|
+
y: int
|
|
27
|
+
z: int
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class A4PrinterInfo:
|
|
32
|
+
"""Structured Adventurer 4 printer info parsed from M115."""
|
|
33
|
+
|
|
34
|
+
machine_type: str
|
|
35
|
+
machine_name: str
|
|
36
|
+
firmware: str
|
|
37
|
+
serial_number: str | None
|
|
38
|
+
build_volume: A4BuildVolume
|
|
39
|
+
tool_count: int
|
|
40
|
+
mac_address: str
|
|
41
|
+
variant: A4PrinterVariant
|
|
42
|
+
raw: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(slots=True)
|
|
46
|
+
class A4FileEntry:
|
|
47
|
+
"""Normalized file entry returned by the Adventurer 4 TCP client."""
|
|
48
|
+
|
|
49
|
+
name: str
|
|
50
|
+
path: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class FlashForgeA4Client(FlashForgeTcpClient):
|
|
54
|
+
"""TCP client for FlashForge Adventurer 4 Lite and Pro printers."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, hostname: str, options: FlashForgeTcpClientOptions | None = None) -> None:
|
|
57
|
+
super().__init__(hostname, options)
|
|
58
|
+
self._control = GCodeController(self)
|
|
59
|
+
|
|
60
|
+
def get_ip(self) -> str:
|
|
61
|
+
"""Return the configured printer hostname or IP address."""
|
|
62
|
+
return self.hostname
|
|
63
|
+
|
|
64
|
+
def gcode(self) -> GCodeController:
|
|
65
|
+
"""Return the shared G-code controller."""
|
|
66
|
+
return self._control
|
|
67
|
+
|
|
68
|
+
async def init_control(self) -> bool:
|
|
69
|
+
"""Initialize control using the legacy M601 S1 login flow."""
|
|
70
|
+
try:
|
|
71
|
+
response = await self.send_command_async(GCodes.CMD_LOGIN)
|
|
72
|
+
if response is None:
|
|
73
|
+
logger.error("A4: failed to send M601 S1 login command")
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
if "Error: have been connected" in response:
|
|
77
|
+
logger.warning("A4: already connected to printer")
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
if not self._is_successful_command_response(GCodes.CMD_LOGIN, response):
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
await asyncio.sleep(0.1)
|
|
84
|
+
info = await self.get_printer_info()
|
|
85
|
+
if not info:
|
|
86
|
+
logger.error("A4: failed to retrieve printer info after M601 S1")
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
await self.start_keep_alive()
|
|
90
|
+
return True
|
|
91
|
+
except Exception as error:
|
|
92
|
+
logger.error("A4: init_control error: %s", error)
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
async def led_on(self) -> bool:
|
|
96
|
+
return await self._control.led_on()
|
|
97
|
+
|
|
98
|
+
async def led_off(self) -> bool:
|
|
99
|
+
return await self._control.led_off()
|
|
100
|
+
|
|
101
|
+
async def pause_job(self) -> bool:
|
|
102
|
+
return await self._control.pause_job()
|
|
103
|
+
|
|
104
|
+
async def resume_job(self) -> bool:
|
|
105
|
+
return await self._control.resume_job()
|
|
106
|
+
|
|
107
|
+
async def stop_job(self) -> bool:
|
|
108
|
+
return await self._control.stop_job()
|
|
109
|
+
|
|
110
|
+
async def start_job(self, name: str) -> bool:
|
|
111
|
+
return await self._control.start_job(name)
|
|
112
|
+
|
|
113
|
+
async def home_axes(self) -> bool:
|
|
114
|
+
return await self._control.home()
|
|
115
|
+
|
|
116
|
+
async def rapid_home(self) -> bool:
|
|
117
|
+
return await self._control.rapid_home()
|
|
118
|
+
|
|
119
|
+
async def set_extruder_temp(self, temp: int, wait_for: bool = False) -> bool:
|
|
120
|
+
return await self._control.set_extruder_temp(temp, wait_for)
|
|
121
|
+
|
|
122
|
+
async def cancel_extruder_temp(self) -> bool:
|
|
123
|
+
return await self._control.cancel_extruder_temp()
|
|
124
|
+
|
|
125
|
+
async def set_bed_temp(self, temp: int, wait_for: bool = False) -> bool:
|
|
126
|
+
return await self._control.set_bed_temp(temp, wait_for)
|
|
127
|
+
|
|
128
|
+
async def cancel_bed_temp(self, wait_for_cool: bool = False) -> bool:
|
|
129
|
+
return await self._control.cancel_bed_temp(wait_for_cool)
|
|
130
|
+
|
|
131
|
+
async def extrude(self, length: float, feedrate: int = 450) -> bool:
|
|
132
|
+
return await self.send_cmd_ok(f"~G1 E{length} F{feedrate}")
|
|
133
|
+
|
|
134
|
+
async def move_extruder(self, x: float, y: float, feedrate: int) -> bool:
|
|
135
|
+
return await self.send_cmd_ok(f"~G1 X{x} Y{y} F{feedrate}")
|
|
136
|
+
|
|
137
|
+
async def move(self, x: float, y: float, z: float, feedrate: int) -> bool:
|
|
138
|
+
return await self.send_cmd_ok(f"~G1 X{x} Y{y} Z{z} F{feedrate}")
|
|
139
|
+
|
|
140
|
+
async def send_cmd_ok(self, cmd: str) -> bool:
|
|
141
|
+
"""Send a command and apply Adventurer 4 success semantics."""
|
|
142
|
+
try:
|
|
143
|
+
response = await self.send_command_async(cmd)
|
|
144
|
+
if response is None:
|
|
145
|
+
return False
|
|
146
|
+
return self._is_successful_command_response(cmd, response)
|
|
147
|
+
except Exception as error:
|
|
148
|
+
logger.error("A4: send_cmd_ok failed for %s: %s", cmd, error)
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
async def send_raw_cmd(self, cmd: str) -> str:
|
|
152
|
+
"""Send a raw command and return the raw string response."""
|
|
153
|
+
if "M661" not in cmd:
|
|
154
|
+
response = await self.send_command_async(cmd)
|
|
155
|
+
return response or ""
|
|
156
|
+
|
|
157
|
+
files = await self.get_file_list_async()
|
|
158
|
+
return "\n".join(files)
|
|
159
|
+
|
|
160
|
+
async def get_printer_info(self) -> A4PrinterInfo | None:
|
|
161
|
+
"""Get printer information using the documented Adventurer 4 M115 response."""
|
|
162
|
+
response = await self.send_command_async(GCodes.CMD_INFO_STATUS)
|
|
163
|
+
if response is None:
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
normalized = self._normalize_a4_text_response(response)
|
|
167
|
+
lines = self._get_normalized_lines(normalized)
|
|
168
|
+
info = A4PrinterInfo(
|
|
169
|
+
machine_type="",
|
|
170
|
+
machine_name="",
|
|
171
|
+
firmware="",
|
|
172
|
+
serial_number=None,
|
|
173
|
+
build_volume=A4BuildVolume(x=220, y=200, z=250),
|
|
174
|
+
tool_count=1,
|
|
175
|
+
mac_address="",
|
|
176
|
+
variant="unknown",
|
|
177
|
+
raw=normalized,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
for line in lines:
|
|
181
|
+
if line.startswith("Machine Type:"):
|
|
182
|
+
info.machine_type = line.replace("Machine Type:", "", 1).strip()
|
|
183
|
+
info.variant = self._detect_variant(info.machine_type)
|
|
184
|
+
elif line.startswith("Machine Name:"):
|
|
185
|
+
info.machine_name = line.replace("Machine Name:", "", 1).strip()
|
|
186
|
+
elif line.startswith("Firmware:"):
|
|
187
|
+
info.firmware = line.replace("Firmware:", "", 1).strip()
|
|
188
|
+
elif line.startswith("SN:"):
|
|
189
|
+
info.serial_number = line.replace("SN:", "", 1).strip()
|
|
190
|
+
elif line.startswith("Serial Number:"):
|
|
191
|
+
info.serial_number = line.replace("Serial Number:", "", 1).strip()
|
|
192
|
+
elif line.startswith("Tool Count:") or line.startswith("Tool count:"):
|
|
193
|
+
try:
|
|
194
|
+
info.tool_count = int(line.split(":", 1)[1].strip())
|
|
195
|
+
except (IndexError, ValueError):
|
|
196
|
+
info.tool_count = 1
|
|
197
|
+
elif line.startswith("Mac Address:"):
|
|
198
|
+
info.mac_address = line.replace("Mac Address:", "", 1).strip()
|
|
199
|
+
elif "X:" in line and "Y:" in line and "Z:" in line:
|
|
200
|
+
volume_tokens = line.replace(":", "").split()
|
|
201
|
+
try:
|
|
202
|
+
info.build_volume = A4BuildVolume(
|
|
203
|
+
x=int(volume_tokens[1]),
|
|
204
|
+
y=int(volume_tokens[3]),
|
|
205
|
+
z=int(volume_tokens[5]),
|
|
206
|
+
)
|
|
207
|
+
except (IndexError, ValueError):
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
if not info.machine_type or not info.firmware:
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
return info
|
|
214
|
+
|
|
215
|
+
async def get_temp_info(self) -> TempInfo | None:
|
|
216
|
+
response = await self.send_command_async(GCodes.CMD_TEMP)
|
|
217
|
+
if response:
|
|
218
|
+
return TempInfo().from_replay(self._normalize_a4_text_response(response))
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
async def get_endstop_status(self) -> EndstopStatus | None:
|
|
222
|
+
response = await self.send_command_async(GCodes.CMD_ENDSTOP_INFO)
|
|
223
|
+
if response:
|
|
224
|
+
return EndstopStatus().from_replay(self._normalize_a4_text_response(response))
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
async def get_endstop_info(self) -> EndstopStatus | None:
|
|
228
|
+
return await self.get_endstop_status()
|
|
229
|
+
|
|
230
|
+
async def get_print_status(self) -> PrintStatus | None:
|
|
231
|
+
response = await self.send_command_async(GCodes.CMD_PRINT_STATUS)
|
|
232
|
+
if response:
|
|
233
|
+
return PrintStatus().from_replay(self._normalize_a4_text_response(response))
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
async def get_location_info(self) -> LocationInfo | None:
|
|
237
|
+
response = await self.send_command_async(GCodes.CMD_INFO_XYZAB)
|
|
238
|
+
if response:
|
|
239
|
+
return LocationInfo().from_replay(self._normalize_a4_text_response(response))
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
async def list_files(self) -> list[A4FileEntry]:
|
|
243
|
+
files = await self.get_file_list_async()
|
|
244
|
+
entries: list[A4FileEntry] = []
|
|
245
|
+
for relative_path in files:
|
|
246
|
+
normalized_path = (
|
|
247
|
+
relative_path if relative_path.startswith("/data/") else f"/data/{relative_path}"
|
|
248
|
+
)
|
|
249
|
+
entries.append(
|
|
250
|
+
A4FileEntry(
|
|
251
|
+
name=normalized_path.split("/")[-1] or relative_path,
|
|
252
|
+
path=normalized_path,
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
return entries
|
|
256
|
+
|
|
257
|
+
async def get_thumbnail(self, file_name: str) -> ThumbnailInfo | None:
|
|
258
|
+
file_path = file_name if file_name.startswith("/data/") else f"/data/{file_name}"
|
|
259
|
+
response = await self.send_command_async(f"{GCodes.CMD_GET_THUMBNAIL} {file_path}")
|
|
260
|
+
if response:
|
|
261
|
+
return ThumbnailInfo().from_replay(response, file_name)
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
def _normalize_a4_text_response(self, response: str) -> str:
|
|
265
|
+
normalized = response.replace("\r\n", "\n").replace("\r", "\n").rstrip()
|
|
266
|
+
lines = normalized.split("\n")
|
|
267
|
+
if lines:
|
|
268
|
+
lines[0] = lines[0].removeprefix("ack: ").removeprefix("echo: ")
|
|
269
|
+
normalized = "\n".join(lines).rstrip()
|
|
270
|
+
|
|
271
|
+
if normalized.startswith('"') and normalized.endswith('"'):
|
|
272
|
+
normalized = normalized[1:-1].rstrip()
|
|
273
|
+
|
|
274
|
+
return normalized
|
|
275
|
+
|
|
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()]
|
|
278
|
+
|
|
279
|
+
def _detect_variant(self, machine_type: str) -> A4PrinterVariant:
|
|
280
|
+
normalized_type = machine_type.upper()
|
|
281
|
+
if "PRO" in normalized_type:
|
|
282
|
+
return "pro"
|
|
283
|
+
if "ADVENTURER 4" in normalized_type or "ADVENTURER4" in normalized_type:
|
|
284
|
+
return "lite"
|
|
285
|
+
return "unknown"
|
|
286
|
+
|
|
287
|
+
def _is_successful_command_response(self, cmd: str, response: str) -> bool:
|
|
288
|
+
normalized = self._normalize_a4_text_response(response).strip()
|
|
289
|
+
if not normalized:
|
|
290
|
+
return False
|
|
291
|
+
|
|
292
|
+
lowered = normalized.lower()
|
|
293
|
+
if "error:" in lowered or "control failed." in lowered:
|
|
294
|
+
return False
|
|
295
|
+
|
|
296
|
+
bare_cmd = cmd.strip().removeprefix("~").split(maxsplit=1)[0]
|
|
297
|
+
if bare_cmd == "M23":
|
|
298
|
+
return "File opened" in normalized or "ok" in lowered
|
|
299
|
+
|
|
300
|
+
return "ok" in lowered or "Received." in normalized or normalized.startswith(bare_cmd)
|