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.
Files changed (43) hide show
  1. flashforge/__init__.py +70 -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 +7 -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 +185 -64
  16. flashforge/discovery/__init__.py +30 -3
  17. flashforge/discovery/discovery.py +641 -308
  18. flashforge/models/__init__.py +11 -10
  19. flashforge/models/machine_info.py +225 -100
  20. flashforge/models/responses.py +103 -43
  21. flashforge/tcp/__init__.py +30 -17
  22. flashforge/tcp/a3_client.py +408 -0
  23. flashforge/tcp/a4_client.py +300 -0
  24. flashforge/tcp/ff_client.py +93 -90
  25. flashforge/tcp/gcode/__init__.py +4 -2
  26. flashforge/tcp/gcode/a3_gcode_controller.py +109 -0
  27. flashforge/tcp/gcode/gcode_controller.py +48 -36
  28. flashforge/tcp/gcode/gcodes.py +7 -1
  29. flashforge/tcp/parsers/__init__.py +11 -11
  30. flashforge/tcp/parsers/endstop_status.py +103 -132
  31. flashforge/tcp/parsers/location_info.py +26 -13
  32. flashforge/tcp/parsers/print_status.py +49 -56
  33. flashforge/tcp/parsers/printer_info.py +38 -48
  34. flashforge/tcp/parsers/temp_info.py +46 -47
  35. flashforge/tcp/parsers/thumbnail_info.py +65 -40
  36. flashforge/tcp/tcp_client.py +296 -293
  37. flashforge_python_api-1.2.0.dist-info/METADATA +133 -0
  38. flashforge_python_api-1.2.0.dist-info/RECORD +46 -0
  39. {flashforge_python_api-1.0.2.dist-info → flashforge_python_api-1.2.0.dist-info}/WHEEL +1 -1
  40. flashforge_python_api-1.0.2.dist-info/METADATA +0 -284
  41. flashforge_python_api-1.0.2.dist-info/RECORD +0 -43
  42. {flashforge_python_api-1.0.2.dist-info → flashforge_python_api-1.2.0.dist-info}/entry_points.txt +0 -0
  43. {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)