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.
- flashforge/__init__.py +62 -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 +1 -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 +131 -63
- flashforge/discovery/__init__.py +30 -3
- flashforge/discovery/discovery.py +637 -308
- flashforge/models/__init__.py +11 -10
- flashforge/models/machine_info.py +220 -100
- flashforge/models/responses.py +103 -43
- flashforge/tcp/__init__.py +25 -17
- flashforge/tcp/a3_client.py +405 -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.0.2.dist-info → flashforge_python_api-1.1.0.dist-info}/METADATA +35 -14
- flashforge_python_api-1.1.0.dist-info/RECORD +45 -0
- {flashforge_python_api-1.0.2.dist-info → flashforge_python_api-1.1.0.dist-info}/WHEEL +1 -1
- flashforge_python_api-1.0.2.dist-info/RECORD +0 -43
- {flashforge_python_api-1.0.2.dist-info → flashforge_python_api-1.1.0.dist-info}/entry_points.txt +0 -0
- {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("~")
|