flashforge-python-api 1.0.2__py3-none-any.whl → 1.1.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.
Files changed (41) hide show
  1. flashforge/__init__.py +62 -29
  2. flashforge/api/__init__.py +1 -0
  3. flashforge/api/constants/__init__.py +1 -0
  4. flashforge/api/constants/commands.py +1 -0
  5. flashforge/api/constants/endpoints.py +1 -0
  6. flashforge/api/controls/__init__.py +1 -0
  7. flashforge/api/controls/control.py +64 -61
  8. flashforge/api/controls/files.py +28 -26
  9. flashforge/api/controls/info.py +84 -85
  10. flashforge/api/controls/job_control.py +120 -82
  11. flashforge/api/controls/temp_control.py +14 -11
  12. flashforge/api/misc/__init__.py +1 -1
  13. flashforge/api/network/__init__.py +2 -1
  14. flashforge/api/network/utils.py +7 -8
  15. flashforge/client.py +131 -63
  16. flashforge/discovery/__init__.py +30 -3
  17. flashforge/discovery/discovery.py +637 -308
  18. flashforge/models/__init__.py +11 -10
  19. flashforge/models/machine_info.py +220 -100
  20. flashforge/models/responses.py +103 -43
  21. flashforge/tcp/__init__.py +25 -17
  22. flashforge/tcp/a3_client.py +405 -0
  23. flashforge/tcp/ff_client.py +93 -90
  24. flashforge/tcp/gcode/__init__.py +4 -2
  25. flashforge/tcp/gcode/a3_gcode_controller.py +109 -0
  26. flashforge/tcp/gcode/gcode_controller.py +48 -36
  27. flashforge/tcp/gcode/gcodes.py +7 -1
  28. flashforge/tcp/parsers/__init__.py +11 -11
  29. flashforge/tcp/parsers/endstop_status.py +103 -132
  30. flashforge/tcp/parsers/location_info.py +26 -13
  31. flashforge/tcp/parsers/print_status.py +49 -56
  32. flashforge/tcp/parsers/printer_info.py +38 -48
  33. flashforge/tcp/parsers/temp_info.py +46 -47
  34. flashforge/tcp/parsers/thumbnail_info.py +65 -40
  35. flashforge/tcp/tcp_client.py +296 -293
  36. {flashforge_python_api-1.0.2.dist-info → flashforge_python_api-1.1.0.dist-info}/METADATA +35 -14
  37. flashforge_python_api-1.1.0.dist-info/RECORD +45 -0
  38. {flashforge_python_api-1.0.2.dist-info → flashforge_python_api-1.1.0.dist-info}/WHEEL +1 -1
  39. flashforge_python_api-1.0.2.dist-info/RECORD +0 -43
  40. {flashforge_python_api-1.0.2.dist-info → flashforge_python_api-1.1.0.dist-info}/entry_points.txt +0 -0
  41. {flashforge_python_api-1.0.2.dist-info → flashforge_python_api-1.1.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,405 @@
1
+ """
2
+ Adventurer 3 TCP client and typed helpers.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import re
9
+ from dataclasses import dataclass
10
+ from typing import TYPE_CHECKING
11
+
12
+ from .parsers import EndstopStatus, LocationInfo, PrintStatus, TempInfo
13
+ from .tcp_client import FlashForgeTcpClient, FlashForgeTcpClientOptions
14
+
15
+ if TYPE_CHECKING:
16
+ from .gcode import A3GCodeController
17
+
18
+
19
+ @dataclass(slots=True)
20
+ class A3BuildVolume:
21
+ """Build volume metadata reported by Adventurer 3 M115 replies."""
22
+
23
+ x: int
24
+ y: int
25
+ z: int
26
+
27
+
28
+ @dataclass(slots=True)
29
+ class A3PrinterInfo:
30
+ """Structured Adventurer 3 printer info parsed from M115."""
31
+
32
+ machine_type: str
33
+ machine_name: str
34
+ firmware: str
35
+ serial_number: str
36
+ build_volume: A3BuildVolume
37
+ tool_count: int
38
+ mac_address: str
39
+ raw: str
40
+
41
+
42
+ @dataclass(slots=True)
43
+ class A3FileEntry:
44
+ """File entry returned by the documented Adventurer 3 M661 response format."""
45
+
46
+ name: str
47
+ path: str
48
+ size: int | None = None
49
+
50
+
51
+ @dataclass(slots=True)
52
+ class A3Thumbnail:
53
+ """Thumbnail payload returned by Adventurer 3 M662."""
54
+
55
+ data: bytes
56
+ width: int | None = None
57
+ height: int | None = None
58
+
59
+
60
+ class FlashForgeA3Client(FlashForgeTcpClient):
61
+ """TCP client for FlashForge Adventurer 3 printers."""
62
+
63
+ def __init__(self, hostname: str, options: FlashForgeTcpClientOptions | None = None) -> None:
64
+ super().__init__(hostname, options)
65
+ self._control: A3GCodeController | None = None
66
+
67
+ def gcode(self) -> A3GCodeController:
68
+ """Return the Adventurer 3 G-code controller."""
69
+ if self._control is None:
70
+ from .gcode import A3GCodeController
71
+
72
+ self._control = A3GCodeController(self)
73
+ return self._control
74
+
75
+ def should_skip_response_wait(self, cmd: str) -> bool:
76
+ bare_cmd = self._strip_protocol_prefix(cmd)
77
+ return bool(re.match(r"^(G1|G28|G90|G91|G92)\b", bare_cmd))
78
+
79
+ def should_use_inactivity_completion(self, cmd: str) -> bool:
80
+ return not self.is_binary_command(cmd) and not self.should_skip_response_wait(cmd)
81
+
82
+ def get_inactivity_completion_delay_ms(self, cmd: str) -> int:
83
+ bare_cmd = self._strip_protocol_prefix(cmd)
84
+ if bare_cmd.startswith("M661"):
85
+ return 500
86
+ if bare_cmd.startswith("M115") or bare_cmd.startswith("M119") or bare_cmd.startswith("M650"):
87
+ return 250
88
+ return 200
89
+
90
+ def get_response_completion_delay_ms(self, cmd: str, binary: bool) -> int:
91
+ if binary and self._strip_protocol_prefix(cmd).startswith("M662"):
92
+ return 0
93
+ return super().get_response_completion_delay_ms(cmd, binary)
94
+
95
+ def is_binary_response_complete(self, cmd: str, response: bytearray) -> bool:
96
+ if not self._strip_protocol_prefix(cmd).startswith("M662"):
97
+ return super().is_binary_response_complete(cmd, response)
98
+
99
+ buffer = bytes(response)
100
+ text_prefix = buffer.decode("utf-8", errors="ignore")
101
+ if "Error: File not exists" in text_prefix:
102
+ return True
103
+
104
+ magic_offset = buffer.find(b"\xA2\xA2\x2A\x2A")
105
+ if magic_offset == -1 or len(buffer) < magic_offset + 8:
106
+ return False
107
+
108
+ length = int.from_bytes(buffer[magic_offset + 4 : magic_offset + 8], byteorder="big")
109
+ return len(buffer) >= magic_offset + 8 + length
110
+
111
+ def normalize_text_response(self, _cmd: str, response: str) -> str:
112
+ return self._normalize_a3_text_response(response)
113
+
114
+ async def init_control(self) -> bool:
115
+ """Initialize control by sending M601."""
116
+ try:
117
+ await asyncio.sleep(0.5)
118
+ response = await self.send_command_async("~M601")
119
+ if response is None:
120
+ return False
121
+ if "Error: have been connected" in response:
122
+ return True
123
+ return self._is_successful_command_response("~M601", response)
124
+ except Exception:
125
+ return False
126
+
127
+ async def get_printer_info(self) -> A3PrinterInfo | None:
128
+ """Get printer information using the documented Adventurer 3 M115 response."""
129
+ response = await self.send_command_async("~M115")
130
+ if response is None:
131
+ return None
132
+
133
+ normalized = self._normalize_a3_text_response(response)
134
+ lines = self._get_normalized_lines(normalized)
135
+ info = A3PrinterInfo(
136
+ machine_type="",
137
+ machine_name="",
138
+ firmware="",
139
+ serial_number="",
140
+ build_volume=A3BuildVolume(x=150, y=150, z=150),
141
+ tool_count=1,
142
+ mac_address="",
143
+ raw=normalized,
144
+ )
145
+
146
+ for line in lines:
147
+ if line.startswith("Machine Type:"):
148
+ info.machine_type = line.replace("Machine Type:", "", 1).strip()
149
+ elif line.startswith("Machine Name:"):
150
+ info.machine_name = line.replace("Machine Name:", "", 1).strip()
151
+ elif line.startswith("Firmware:"):
152
+ info.firmware = line.replace("Firmware:", "", 1).strip()
153
+ elif line.startswith("Serial Number:"):
154
+ info.serial_number = line.replace("Serial Number:", "", 1).strip()
155
+ elif line.startswith("Tool Count:"):
156
+ try:
157
+ info.tool_count = int(line.replace("Tool Count:", "", 1).strip())
158
+ except ValueError:
159
+ info.tool_count = 1
160
+ elif line.startswith("Mac Address:"):
161
+ info.mac_address = line.replace("Mac Address:", "", 1).strip()
162
+ else:
163
+ volume_match = re.search(r"X:\s*(\d+)\s+Y:\s*(\d+)\s+Z:\s*(\d+)", line, re.IGNORECASE)
164
+ if volume_match:
165
+ info.build_volume = A3BuildVolume(
166
+ x=int(volume_match.group(1)),
167
+ y=int(volume_match.group(2)),
168
+ z=int(volume_match.group(3)),
169
+ )
170
+
171
+ if not info.machine_type or not info.firmware:
172
+ return None
173
+ return info
174
+
175
+ async def get_endstop_status(self) -> EndstopStatus | None:
176
+ """Get the printer endstop and machine status using M119."""
177
+ response = await self.send_command_async("~M119")
178
+ if response is None:
179
+ return None
180
+ return EndstopStatus().from_replay(self._normalize_a3_text_response(response))
181
+
182
+ async def set_printer_name(self, name: str) -> bool:
183
+ """Set the printer name using M610."""
184
+ return await self.send_cmd_ok(f"~M610 {name}")
185
+
186
+ async def list_files(self) -> list[A3FileEntry]:
187
+ """List files from printer storage using M661."""
188
+ response = await self.send_command_async("~M661")
189
+ if response is None:
190
+ return []
191
+ return self._parse_file_list(response)
192
+
193
+ async def get_thumbnail(self, filename: str) -> A3Thumbnail | None:
194
+ """Get a file thumbnail using M662."""
195
+ response = await self.send_command_async(f"~M662 {filename}")
196
+ if response is None:
197
+ return None
198
+ return self._parse_thumbnail(response)
199
+
200
+ async def select_file(self, filename: str) -> bool:
201
+ """Select a file for printing using M23."""
202
+ resolved_path = filename if filename.startswith("/") else f"/data/{filename}"
203
+ return await self.send_cmd_ok(f"~M23 {resolved_path}")
204
+
205
+ async def start_print(self) -> bool:
206
+ """Start the selected print job using M24."""
207
+ return await self.send_cmd_ok("~M24")
208
+
209
+ async def pause_print(self) -> bool:
210
+ """Pause the active print job using M25."""
211
+ return await self.send_cmd_ok("~M25")
212
+
213
+ async def stop_print(self) -> bool:
214
+ """Stop the active print job using M26."""
215
+ return await self.send_cmd_ok("~M26")
216
+
217
+ async def get_print_status(self) -> PrintStatus | None:
218
+ """Get print progress using M27."""
219
+ response = await self.send_command_async("~M27")
220
+ if response is None:
221
+ return None
222
+ return PrintStatus().from_replay(self._normalize_a3_text_response(response))
223
+
224
+ async def get_position(self) -> LocationInfo | None:
225
+ """Get the current position using M114."""
226
+ response = await self.send_command_async("~M114")
227
+ if response is None:
228
+ return None
229
+ return LocationInfo().from_replay(self._normalize_a3_text_response(response))
230
+
231
+ async def get_position_xyze(self) -> LocationInfo | None:
232
+ """Get the detailed position using M663 when the firmware supports it."""
233
+ response = await self.send_command_async("~M663")
234
+ if response is None:
235
+ return None
236
+ return LocationInfo().from_replay(self._normalize_a3_text_response(response))
237
+
238
+ async def home(self) -> bool:
239
+ """Home all axes using G28."""
240
+ return await self.send_cmd_ok("~G28")
241
+
242
+ async def move(self, x: float, y: float, z: float, feedrate: int) -> bool:
243
+ """Move the print head using G1."""
244
+ return await self.send_cmd_ok(f"~G1 X{x} Y{y} Z{z} F{feedrate}")
245
+
246
+ async def get_temp_info(self) -> TempInfo | None:
247
+ """Get temperature information using M105."""
248
+ response = await self.send_command_async("~M105")
249
+ if response is None:
250
+ return None
251
+ return TempInfo().from_replay(self._normalize_a3_text_response(response))
252
+
253
+ async def send_cmd_ok(self, cmd: str) -> bool:
254
+ """Send a command and apply Adventurer 3 success semantics."""
255
+ try:
256
+ mapped_cmd = self._map_controller_command(cmd)
257
+ response = await self.send_command_async(mapped_cmd)
258
+ if response is None:
259
+ return False
260
+ if self.should_skip_response_wait(mapped_cmd):
261
+ return True
262
+ return self._is_successful_command_response(mapped_cmd, response)
263
+ except Exception:
264
+ return False
265
+
266
+ async def enable_motors(self) -> bool:
267
+ """Enable stepper motors using M17."""
268
+ return await self.send_cmd_ok("~M17")
269
+
270
+ async def disable_motors(self) -> bool:
271
+ """Disable stepper motors using M18."""
272
+ return await self.send_cmd_ok("~M18")
273
+
274
+ async def emergency_stop(self) -> bool:
275
+ """Perform an emergency stop using M112."""
276
+ return await self.send_cmd_ok("~M112")
277
+
278
+ async def cancel_heat_wait(self) -> bool:
279
+ """Send M108. The Adventurer 3 firmware acknowledges it but treats it as a no-op."""
280
+ return await self.send_cmd_ok("~M108")
281
+
282
+ async def led_control(self, params: str) -> bool:
283
+ """Control the accessory LED bar using M146."""
284
+ return await self.send_cmd_ok(f"~M146 {params}")
285
+
286
+ async def custom_m144(self, params: str | None = None) -> str | None:
287
+ """Send M144 directly."""
288
+ cmd = f"~M144 {params}" if params else "~M144"
289
+ return await self.send_command_async(cmd)
290
+
291
+ async def custom_m145(self, params: str | None = None) -> str | None:
292
+ """Send M145 directly."""
293
+ cmd = f"~M145 {params}" if params else "~M145"
294
+ return await self.send_command_async(cmd)
295
+
296
+ async def custom_m611(self) -> str | None:
297
+ """Send M611 directly."""
298
+ return await self.send_command_async("~M611")
299
+
300
+ async def custom_m612(self) -> str | None:
301
+ """Send M612 directly."""
302
+ return await self.send_command_async("~M612")
303
+
304
+ async def custom_m650(self) -> str | None:
305
+ """Send M650 directly."""
306
+ return await self.send_command_async("~M650")
307
+
308
+ async def custom_m651(self) -> str | None:
309
+ """Send M651 directly."""
310
+ return await self.send_command_async("~M651")
311
+
312
+ async def custom_m652(self) -> str | None:
313
+ """Send M652 directly."""
314
+ return await self.send_command_async("~M652")
315
+
316
+ async def custom_m653(self, params: str) -> bool:
317
+ """Send M653 with parameters."""
318
+ return await self.send_cmd_ok(f"~M653 {params}")
319
+
320
+ async def custom_m654(self, params: str) -> bool:
321
+ """Send M654 with parameters."""
322
+ return await self.send_cmd_ok(f"~M654 {params}")
323
+
324
+ def _parse_file_list(self, response: str) -> list[A3FileEntry]:
325
+ lines = self._get_normalized_lines(response)
326
+ if any("CMD M661 Error." in line for line in lines):
327
+ return []
328
+
329
+ files: list[A3FileEntry] = []
330
+ count_index = next(
331
+ (index for index, line in enumerate(lines) if re.search(r"info_list\.size:\s*\d+", line)),
332
+ -1,
333
+ )
334
+ if count_index == -1:
335
+ return files
336
+
337
+ count_match = re.search(r"info_list\.size:\s*(\d+)", lines[count_index], re.IGNORECASE)
338
+ file_count = int(count_match.group(1)) if count_match else 0
339
+ for line in lines[count_index + 1 :]:
340
+ if len(files) >= file_count:
341
+ break
342
+ if not line or line == "ok" or line.startswith("CMD "):
343
+ continue
344
+ files.append(A3FileEntry(name=line, path=f"/data/{line}"))
345
+
346
+ return files
347
+
348
+ def _parse_thumbnail(self, response: str) -> A3Thumbnail | None:
349
+ buffer = response.encode("latin1")
350
+ error_text = buffer.decode("utf-8", errors="ignore")
351
+ if "Error: File not exists" in error_text:
352
+ return None
353
+
354
+ magic_offset = buffer.find(b"\xA2\xA2\x2A\x2A")
355
+ if magic_offset == -1 or len(buffer) < magic_offset + 8:
356
+ return None
357
+
358
+ payload_length = int.from_bytes(buffer[magic_offset + 4 : magic_offset + 8], byteorder="big")
359
+ payload_end = magic_offset + 8 + payload_length
360
+ if len(buffer) < payload_end:
361
+ return None
362
+
363
+ return A3Thumbnail(data=buffer[magic_offset + 8 : payload_end])
364
+
365
+ def _normalize_a3_text_response(self, response: str) -> str:
366
+ normalized = response.replace("\r\n", "\n").replace("\r", "\n")
367
+ lines = normalized.split("\n")
368
+ if lines:
369
+ lines[0] = re.sub(r"^(ack|echo):\s*", "", lines[0], flags=re.IGNORECASE)
370
+ normalized = "\n".join(lines).rstrip()
371
+
372
+ if normalized.startswith('"') and normalized.endswith('"'):
373
+ normalized = normalized[1:-1]
374
+
375
+ return normalized.rstrip()
376
+
377
+ def _get_normalized_lines(self, response: str) -> list[str]:
378
+ return [line.strip() for line in self._normalize_a3_text_response(response).split("\n") if line.strip()]
379
+
380
+ def _map_controller_command(self, cmd: str) -> str:
381
+ if cmd == "~M146 r255 g255 b255 F0":
382
+ return "~M146 1"
383
+ if cmd == "~M146 r0 g0 b0 F0":
384
+ return "~M146 0"
385
+ return cmd
386
+
387
+ def _is_successful_command_response(self, cmd: str, response: str) -> bool:
388
+ normalized = self._normalize_a3_text_response(response).strip()
389
+ if not normalized:
390
+ return self.should_skip_response_wait(cmd)
391
+
392
+ if "Error:" in normalized or "Control failed." in normalized:
393
+ return False
394
+
395
+ bare_cmd = self._strip_protocol_prefix(cmd)
396
+ if bare_cmd.startswith("M23"):
397
+ return "File opened" in normalized or "ok" in normalized
398
+ if bare_cmd.startswith("M112"):
399
+ return bool(re.search(r"Emergency Stop", normalized, re.IGNORECASE)) or "Received." in normalized
400
+ if "ok" in normalized or "Received." in normalized:
401
+ return True
402
+ return normalized.startswith(bare_cmd)
403
+
404
+ def _strip_protocol_prefix(self, cmd: str) -> str:
405
+ return cmd.strip().removeprefix("~")