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
|
@@ -1,388 +1,721 @@
|
|
|
1
1
|
"""
|
|
2
|
-
FlashForge
|
|
3
|
-
|
|
4
|
-
UDP-based printer discovery implementation that finds FlashForge printers on the local network.
|
|
5
|
-
Fixed to match the original TypeScript implementation behavior.
|
|
2
|
+
Universal FlashForge printer discovery using UDP broadcast and multicast.
|
|
6
3
|
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
7
|
import asyncio
|
|
8
8
|
import logging
|
|
9
9
|
import socket
|
|
10
|
-
from
|
|
11
|
-
from
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from enum import IntEnum, StrEnum
|
|
13
|
+
from typing import Any
|
|
12
14
|
|
|
13
15
|
import netifaces
|
|
14
16
|
|
|
15
17
|
logger = logging.getLogger(__name__)
|
|
16
18
|
|
|
19
|
+
MULTICAST_ADDRESS = "225.0.0.9"
|
|
20
|
+
MODERN_PROTOCOL_SIZE = 276
|
|
21
|
+
LEGACY_PROTOCOL_SIZE = 140
|
|
22
|
+
|
|
23
|
+
LEGACY_PRODUCT_IDS = {
|
|
24
|
+
"Adventurer3": 0x0008,
|
|
25
|
+
"Adventurer4Lite": 0x0016,
|
|
26
|
+
"Adventurer4Pro": 0x001E,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DiscoveryError(Exception):
|
|
31
|
+
"""Base error for discovery-related failures."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, message: str, code: str) -> None:
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.code = code
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class InvalidResponseError(DiscoveryError):
|
|
39
|
+
"""Raised when a discovery response has an unexpected size."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, size: int, address: str) -> None:
|
|
42
|
+
super().__init__(f"Invalid response size: {size} bytes from {address}", "INVALID_RESPONSE")
|
|
43
|
+
self.response_size = size
|
|
44
|
+
self.address = address
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SocketCreationError(DiscoveryError):
|
|
48
|
+
"""Raised when a UDP discovery socket cannot be created."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, message: str) -> None:
|
|
51
|
+
super().__init__(message, "SOCKET_CREATION_FAILED")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class DiscoveryTimeoutError(DiscoveryError):
|
|
55
|
+
"""Raised when discovery times out without any responses."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, timeout_ms: int) -> None:
|
|
58
|
+
super().__init__(f"Discovery timeout after {timeout_ms}ms", "DISCOVERY_TIMEOUT")
|
|
59
|
+
self.timeout_ms = timeout_ms
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class PrinterModel(StrEnum):
|
|
63
|
+
"""Known FlashForge printer models discoverable on LAN."""
|
|
17
64
|
|
|
18
|
-
|
|
65
|
+
AD5X = "AD5X"
|
|
66
|
+
ADVENTURER_5M = "Adventurer5M"
|
|
67
|
+
ADVENTURER_5M_PRO = "Adventurer5MPro"
|
|
68
|
+
ADVENTURER_4 = "Adventurer4"
|
|
69
|
+
ADVENTURER_3 = "Adventurer3"
|
|
70
|
+
UNKNOWN = "Unknown"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class DiscoveryProtocol(StrEnum):
|
|
74
|
+
"""Discovery response wire formats."""
|
|
75
|
+
|
|
76
|
+
MODERN = "modern"
|
|
77
|
+
LEGACY = "legacy"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class PrinterStatus(IntEnum):
|
|
81
|
+
"""Printer status reported via UDP discovery."""
|
|
82
|
+
|
|
83
|
+
READY = 0
|
|
84
|
+
BUSY = 1
|
|
85
|
+
ERROR = 2
|
|
86
|
+
UNKNOWN = 3
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(slots=True)
|
|
90
|
+
class DiscoveredPrinter:
|
|
91
|
+
"""Typed printer metadata returned by universal discovery."""
|
|
92
|
+
|
|
93
|
+
model: PrinterModel
|
|
94
|
+
protocol_format: DiscoveryProtocol
|
|
95
|
+
name: str
|
|
96
|
+
ip_address: str
|
|
97
|
+
command_port: int
|
|
98
|
+
serial_number: str | None = None
|
|
99
|
+
event_port: int | None = None
|
|
100
|
+
vendor_id: int | None = None
|
|
101
|
+
product_id: int | None = None
|
|
102
|
+
product_type: int | None = None
|
|
103
|
+
status_code: int | None = None
|
|
104
|
+
status: PrinterStatus | None = None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(slots=True)
|
|
108
|
+
class DiscoveryOptions:
|
|
109
|
+
"""Configuration options for printer discovery."""
|
|
110
|
+
|
|
111
|
+
timeout: int = 10000
|
|
112
|
+
idle_timeout: int = 1500
|
|
113
|
+
max_retries: int = 3
|
|
114
|
+
use_multicast: bool = True
|
|
115
|
+
use_broadcast: bool = True
|
|
116
|
+
ports: list[int] = field(default_factory=lambda: [8899, 19000, 48899])
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(slots=True)
|
|
19
120
|
class FlashForgePrinter:
|
|
20
121
|
"""
|
|
21
|
-
|
|
22
|
-
|
|
122
|
+
Backward-compatible legacy discovery result.
|
|
123
|
+
|
|
124
|
+
This keeps the historical Python surface used by ff-5mp-hass while the richer
|
|
125
|
+
DiscoveredPrinter metadata is exposed via PrinterDiscovery.
|
|
23
126
|
"""
|
|
127
|
+
|
|
24
128
|
name: str = ""
|
|
25
129
|
serial_number: str = ""
|
|
26
130
|
ip_address: str = ""
|
|
27
131
|
|
|
132
|
+
@classmethod
|
|
133
|
+
def from_discovered_printer(cls, printer: DiscoveredPrinter) -> FlashForgePrinter:
|
|
134
|
+
return cls(
|
|
135
|
+
name=printer.name,
|
|
136
|
+
serial_number=printer.serial_number or "",
|
|
137
|
+
ip_address=printer.ip_address,
|
|
138
|
+
)
|
|
139
|
+
|
|
28
140
|
def __str__(self) -> str:
|
|
29
|
-
"""Returns a string representation of the FlashForgePrinter object."""
|
|
30
141
|
return f"Name: {self.name}, Serial: {self.serial_number}, IP: {self.ip_address}"
|
|
31
142
|
|
|
32
143
|
def __repr__(self) -> str:
|
|
33
|
-
|
|
34
|
-
|
|
144
|
+
return (
|
|
145
|
+
"FlashForgePrinter("
|
|
146
|
+
f"name='{self.name}', serial_number='{self.serial_number}', ip_address='{self.ip_address}')"
|
|
147
|
+
)
|
|
35
148
|
|
|
36
149
|
|
|
37
|
-
class
|
|
38
|
-
"""
|
|
39
|
-
Handles the discovery of FlashForge printers on the local network.
|
|
40
|
-
Uses UDP broadcast messages to find printers and parses their responses.
|
|
41
|
-
"""
|
|
150
|
+
class _DiscoveryDatagramProtocol(asyncio.DatagramProtocol):
|
|
151
|
+
"""Asyncio protocol used to push UDP datagrams into an async queue."""
|
|
42
152
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
153
|
+
def __init__(
|
|
154
|
+
self,
|
|
155
|
+
message_queue: asyncio.Queue[tuple[bytes, tuple[str, int]]],
|
|
156
|
+
error_queue: asyncio.Queue[Exception],
|
|
157
|
+
) -> None:
|
|
158
|
+
self.message_queue = message_queue
|
|
159
|
+
self.error_queue = error_queue
|
|
160
|
+
|
|
161
|
+
def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
|
|
162
|
+
self.message_queue.put_nowait((data, addr))
|
|
163
|
+
|
|
164
|
+
def error_received(self, exc: Exception) -> None:
|
|
165
|
+
self.error_queue.put_nowait(exc)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class DiscoveryMonitor:
|
|
169
|
+
"""Event-based continuous discovery monitor."""
|
|
170
|
+
|
|
171
|
+
def __init__(self, discovery: PrinterDiscovery, options: DiscoveryOptions) -> None:
|
|
172
|
+
self._discovery = discovery
|
|
173
|
+
self._options = options
|
|
174
|
+
self._listeners: dict[str, list[Callable[..., Any]]] = {
|
|
175
|
+
"discovered": [],
|
|
176
|
+
"end": [],
|
|
177
|
+
"error": [],
|
|
178
|
+
}
|
|
179
|
+
self._transport: asyncio.DatagramTransport | None = None
|
|
180
|
+
self._task = asyncio.create_task(self._run())
|
|
181
|
+
self._stopped = False
|
|
182
|
+
self._end_emitted = False
|
|
183
|
+
|
|
184
|
+
def on(self, event: str, callback: Callable[..., Any]) -> DiscoveryMonitor:
|
|
185
|
+
"""Register an event callback."""
|
|
186
|
+
self._listeners.setdefault(event, []).append(callback)
|
|
187
|
+
return self
|
|
188
|
+
|
|
189
|
+
def stop(self) -> None:
|
|
190
|
+
"""Stop monitoring and clean up resources."""
|
|
191
|
+
if self._stopped:
|
|
192
|
+
return
|
|
193
|
+
self._stopped = True
|
|
194
|
+
if self._transport:
|
|
195
|
+
self._transport.close()
|
|
196
|
+
self._transport = None
|
|
197
|
+
if not self._task.done():
|
|
198
|
+
self._task.cancel()
|
|
199
|
+
self._emit_end_if_needed()
|
|
200
|
+
|
|
201
|
+
async def _run(self) -> None:
|
|
202
|
+
discovered_keys: set[str] = set()
|
|
203
|
+
message_queue: asyncio.Queue[tuple[bytes, tuple[str, int]]] = asyncio.Queue()
|
|
204
|
+
error_queue: asyncio.Queue[Exception] = asyncio.Queue()
|
|
205
|
+
last_response_at: float | None = None
|
|
206
|
+
send_interval_seconds = self._options.timeout / 1000.0
|
|
207
|
+
total_timeout_seconds = (self._options.timeout * self._options.max_retries) / 1000.0
|
|
47
208
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
self.listen_port = self.LISTEN_PORT
|
|
209
|
+
try:
|
|
210
|
+
self._transport, _ = await self._discovery._create_endpoint(message_queue, error_queue)
|
|
211
|
+
self._discovery._send_discovery_packets(self._transport, self._options)
|
|
52
212
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
self.discovery_message = bytes([
|
|
57
|
-
0x77, 0x77, 0x77, 0x2e, 0x75, 0x73, 0x72, 0x22, # "www.usr"
|
|
58
|
-
0x65, 0x36, 0xc0, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
59
|
-
0x00, 0x00, 0x00, 0x00
|
|
60
|
-
])
|
|
213
|
+
loop = asyncio.get_running_loop()
|
|
214
|
+
started_at = loop.time()
|
|
215
|
+
next_send_at = started_at + send_interval_seconds
|
|
61
216
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
max_retries: int = 3
|
|
67
|
-
) -> List[FlashForgePrinter]:
|
|
68
|
-
"""
|
|
69
|
-
Discovers FlashForge printers on the network asynchronously.
|
|
70
|
-
|
|
71
|
-
FIXED VERSION - Uses proper asyncio UDP socket handling that matches the TypeScript implementation.
|
|
72
|
-
|
|
73
|
-
Args:
|
|
74
|
-
timeout_ms: The total time (in milliseconds) to wait for printer responses
|
|
75
|
-
idle_timeout_ms: The time (in milliseconds) to wait for additional responses
|
|
76
|
-
after the last received one
|
|
77
|
-
max_retries: The maximum number of discovery attempts
|
|
78
|
-
|
|
79
|
-
Returns:
|
|
80
|
-
A list of FlashForgePrinter objects found on the network
|
|
81
|
-
"""
|
|
82
|
-
printers: List[FlashForgePrinter] = []
|
|
83
|
-
broadcast_addresses = self._get_broadcast_addresses()
|
|
84
|
-
attempt = 0
|
|
85
|
-
|
|
86
|
-
logger.info(f"Starting printer discovery with {len(broadcast_addresses)} broadcast addresses")
|
|
87
|
-
|
|
88
|
-
while attempt < max_retries:
|
|
89
|
-
attempt += 1
|
|
90
|
-
logger.debug(f"Discovery attempt {attempt}/{max_retries}")
|
|
217
|
+
while not self._stopped:
|
|
218
|
+
now = loop.time()
|
|
219
|
+
if now - started_at >= total_timeout_seconds:
|
|
220
|
+
break
|
|
91
221
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
allow_broadcast=True
|
|
98
|
-
)
|
|
222
|
+
if (
|
|
223
|
+
last_response_at is not None
|
|
224
|
+
and now - last_response_at >= self._options.idle_timeout / 1000.0
|
|
225
|
+
):
|
|
226
|
+
break
|
|
99
227
|
|
|
228
|
+
if now >= next_send_at:
|
|
229
|
+
self._discovery._send_discovery_packets(self._transport, self._options)
|
|
230
|
+
next_send_at = now + send_interval_seconds
|
|
231
|
+
|
|
232
|
+
wait_timeout = min(0.1, max(total_timeout_seconds - (now - started_at), 0.0))
|
|
100
233
|
try:
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
logger.warning(f"Failed to send to {broadcast_address}: {e}")
|
|
108
|
-
|
|
109
|
-
# Wait for responses using the protocol
|
|
110
|
-
found_printers = await protocol.wait_for_responses(
|
|
111
|
-
timeout_ms / 1000.0, idle_timeout_ms / 1000.0
|
|
112
|
-
)
|
|
113
|
-
printers.extend(found_printers)
|
|
234
|
+
data, addr = await asyncio.wait_for(message_queue.get(), timeout=wait_timeout)
|
|
235
|
+
except TimeoutError:
|
|
236
|
+
if not error_queue.empty():
|
|
237
|
+
error = error_queue.get_nowait()
|
|
238
|
+
self._emit("error", error)
|
|
239
|
+
continue
|
|
114
240
|
|
|
115
|
-
|
|
116
|
-
|
|
241
|
+
printer = self._discovery.parse_discovery_response(data, addr[0])
|
|
242
|
+
if printer is None:
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
last_response_at = loop.time()
|
|
246
|
+
key = f"{printer.ip_address}:{printer.command_port}"
|
|
247
|
+
if key not in discovered_keys:
|
|
248
|
+
discovered_keys.add(key)
|
|
249
|
+
self._emit("discovered", printer)
|
|
250
|
+
except asyncio.CancelledError:
|
|
251
|
+
raise
|
|
252
|
+
except Exception as error:
|
|
253
|
+
self._emit("error", error)
|
|
254
|
+
finally:
|
|
255
|
+
if self._transport:
|
|
256
|
+
self._transport.close()
|
|
257
|
+
self._transport = None
|
|
258
|
+
self._emit_end_if_needed()
|
|
259
|
+
|
|
260
|
+
def _emit(self, event: str, *args: Any) -> None:
|
|
261
|
+
listeners = list(self._listeners.get(event, []))
|
|
262
|
+
if event == "error" and not listeners:
|
|
263
|
+
logger.error("Discovery monitor error: %s", args[0] if args else "unknown error")
|
|
264
|
+
return
|
|
265
|
+
for callback in listeners:
|
|
266
|
+
callback(*args)
|
|
267
|
+
|
|
268
|
+
def _emit_end_if_needed(self) -> None:
|
|
269
|
+
if self._end_emitted:
|
|
270
|
+
return
|
|
271
|
+
self._end_emitted = True
|
|
272
|
+
for callback in list(self._listeners.get("end", [])):
|
|
273
|
+
callback()
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class PrinterDiscovery:
|
|
277
|
+
"""Universal FlashForge printer discovery using UDP broadcast and multicast."""
|
|
278
|
+
|
|
279
|
+
async def discover(self, options: DiscoveryOptions | None = None) -> list[DiscoveredPrinter]:
|
|
280
|
+
config = options or DiscoveryOptions()
|
|
281
|
+
printers: dict[str, DiscoveredPrinter] = {}
|
|
282
|
+
|
|
283
|
+
for attempt in range(config.max_retries):
|
|
284
|
+
message_queue: asyncio.Queue[tuple[bytes, tuple[str, int]]] = asyncio.Queue()
|
|
285
|
+
error_queue: asyncio.Queue[Exception] = asyncio.Queue()
|
|
286
|
+
transport: asyncio.DatagramTransport | None = None
|
|
287
|
+
try:
|
|
288
|
+
transport, _ = await self._create_endpoint(message_queue, error_queue)
|
|
289
|
+
self._send_discovery_packets(transport, config)
|
|
290
|
+
discovered_printers = await self._receive_responses(message_queue, error_queue, config)
|
|
291
|
+
|
|
292
|
+
for printer in discovered_printers:
|
|
293
|
+
key = f"{printer.ip_address}:{printer.command_port}"
|
|
294
|
+
existing = printers.get(key)
|
|
295
|
+
if existing is None or printer.protocol_format == DiscoveryProtocol.MODERN:
|
|
296
|
+
printers[key] = printer
|
|
117
297
|
|
|
118
298
|
if printers:
|
|
119
|
-
break
|
|
120
|
-
|
|
121
|
-
if
|
|
122
|
-
|
|
123
|
-
await asyncio.sleep(1.0) # Wait before retrying
|
|
124
|
-
|
|
125
|
-
except Exception as e:
|
|
126
|
-
logger.error(f"Error during discovery attempt {attempt}: {e}")
|
|
127
|
-
if attempt < max_retries:
|
|
128
|
-
await asyncio.sleep(1.0)
|
|
129
|
-
|
|
130
|
-
# Remove duplicates based on IP address
|
|
131
|
-
unique_printers = {}
|
|
132
|
-
for printer in printers:
|
|
133
|
-
if printer.ip_address not in unique_printers:
|
|
134
|
-
unique_printers[printer.ip_address] = printer
|
|
135
|
-
|
|
136
|
-
final_printers = list(unique_printers.values())
|
|
137
|
-
logger.info(f"Discovery completed. Found {len(final_printers)} unique printers")
|
|
138
|
-
|
|
139
|
-
return final_printers
|
|
140
|
-
|
|
141
|
-
def _parse_printer_response(self, response: bytes, ip_address: str) -> Optional[FlashForgePrinter]:
|
|
142
|
-
"""
|
|
143
|
-
Parses the UDP response received from a FlashForge printer.
|
|
144
|
-
|
|
145
|
-
The response is a buffer containing printer information at specific offsets:
|
|
146
|
-
- Printer Name: ASCII string at offset 0x00 (32 bytes)
|
|
147
|
-
- Serial Number: ASCII string at offset 0x92 (32 bytes)
|
|
148
|
-
|
|
149
|
-
Args:
|
|
150
|
-
response: The bytes containing the printer's response
|
|
151
|
-
ip_address: The IP address from which the response was received
|
|
152
|
-
|
|
153
|
-
Returns:
|
|
154
|
-
A FlashForgePrinter object if parsing is successful, otherwise None
|
|
155
|
-
"""
|
|
156
|
-
# Expected response length is at least 0xC4 (196 bytes) to contain name and serial
|
|
157
|
-
if not response:
|
|
158
|
-
logger.warning(f"Invalid response from {ip_address}: response is None or empty")
|
|
159
|
-
return None
|
|
299
|
+
break
|
|
300
|
+
finally:
|
|
301
|
+
if transport is not None:
|
|
302
|
+
transport.close()
|
|
160
303
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
return None
|
|
304
|
+
if attempt < config.max_retries - 1:
|
|
305
|
+
await asyncio.sleep(1.0)
|
|
164
306
|
|
|
307
|
+
return list(printers.values())
|
|
308
|
+
|
|
309
|
+
def monitor(self, options: DiscoveryOptions | None = None) -> DiscoveryMonitor:
|
|
310
|
+
"""Create an event-based continuous discovery monitor."""
|
|
311
|
+
return DiscoveryMonitor(self, options or DiscoveryOptions())
|
|
312
|
+
|
|
313
|
+
async def _create_endpoint(
|
|
314
|
+
self,
|
|
315
|
+
message_queue: asyncio.Queue[tuple[bytes, tuple[str, int]]],
|
|
316
|
+
error_queue: asyncio.Queue[Exception],
|
|
317
|
+
) -> tuple[asyncio.DatagramTransport, _DiscoveryDatagramProtocol]:
|
|
318
|
+
loop = asyncio.get_running_loop()
|
|
165
319
|
try:
|
|
166
|
-
|
|
167
|
-
|
|
320
|
+
transport, protocol = await loop.create_datagram_endpoint(
|
|
321
|
+
lambda: _DiscoveryDatagramProtocol(message_queue, error_queue),
|
|
322
|
+
local_addr=("0.0.0.0", 0),
|
|
323
|
+
allow_broadcast=True,
|
|
324
|
+
)
|
|
325
|
+
except Exception as error:
|
|
326
|
+
raise SocketCreationError(str(error)) from error
|
|
327
|
+
return transport, protocol
|
|
168
328
|
|
|
169
|
-
|
|
170
|
-
|
|
329
|
+
async def _receive_responses(
|
|
330
|
+
self,
|
|
331
|
+
message_queue: asyncio.Queue[tuple[bytes, tuple[str, int]]],
|
|
332
|
+
error_queue: asyncio.Queue[Exception],
|
|
333
|
+
options: DiscoveryOptions,
|
|
334
|
+
) -> list[DiscoveredPrinter]:
|
|
335
|
+
printers: list[DiscoveredPrinter] = []
|
|
336
|
+
loop = asyncio.get_running_loop()
|
|
337
|
+
started_at = loop.time()
|
|
338
|
+
last_response_at = started_at
|
|
339
|
+
total_timeout_seconds = options.timeout / 1000.0
|
|
340
|
+
idle_timeout_seconds = options.idle_timeout / 1000.0
|
|
171
341
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
342
|
+
while True:
|
|
343
|
+
now = loop.time()
|
|
344
|
+
if now - started_at >= total_timeout_seconds:
|
|
345
|
+
break
|
|
346
|
+
if now - last_response_at >= idle_timeout_seconds:
|
|
347
|
+
break
|
|
175
348
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
349
|
+
try:
|
|
350
|
+
data, addr = await asyncio.wait_for(message_queue.get(), timeout=0.1)
|
|
351
|
+
except TimeoutError:
|
|
352
|
+
if not error_queue.empty():
|
|
353
|
+
logger.error("Socket error during discovery: %s", error_queue.get_nowait())
|
|
354
|
+
continue
|
|
355
|
+
|
|
356
|
+
last_response_at = loop.time()
|
|
357
|
+
printer = self.parse_discovery_response(data, addr[0])
|
|
358
|
+
if printer is not None:
|
|
359
|
+
printers.append(printer)
|
|
181
360
|
|
|
182
|
-
|
|
361
|
+
return printers
|
|
183
362
|
|
|
184
|
-
|
|
185
|
-
|
|
363
|
+
def _send_discovery_packets(
|
|
364
|
+
self,
|
|
365
|
+
transport: asyncio.DatagramTransport,
|
|
366
|
+
options: DiscoveryOptions,
|
|
367
|
+
) -> None:
|
|
368
|
+
packet = b""
|
|
369
|
+
|
|
370
|
+
if options.use_multicast:
|
|
371
|
+
self._join_multicast_group(transport)
|
|
372
|
+
for port in options.ports:
|
|
373
|
+
if port in {8899, 19000}:
|
|
374
|
+
try:
|
|
375
|
+
transport.sendto(packet, (MULTICAST_ADDRESS, port))
|
|
376
|
+
except Exception as error:
|
|
377
|
+
logger.warning(
|
|
378
|
+
"Discovery: Failed to send multicast to %s:%s - %s",
|
|
379
|
+
MULTICAST_ADDRESS,
|
|
380
|
+
port,
|
|
381
|
+
error,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
if options.use_broadcast:
|
|
385
|
+
for address in self.get_broadcast_addresses():
|
|
386
|
+
for port in options.ports:
|
|
387
|
+
if port == 48899:
|
|
388
|
+
try:
|
|
389
|
+
transport.sendto(packet, (address, port))
|
|
390
|
+
except Exception as error:
|
|
391
|
+
logger.warning(
|
|
392
|
+
"Discovery: Failed to send broadcast to %s:%s - %s",
|
|
393
|
+
address,
|
|
394
|
+
port,
|
|
395
|
+
error,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
for port in options.ports:
|
|
399
|
+
try:
|
|
400
|
+
transport.sendto(packet, ("255.255.255.255", port))
|
|
401
|
+
except Exception as error:
|
|
402
|
+
logger.warning(
|
|
403
|
+
"Discovery: Failed to send to broadcast 255.255.255.255:%s - %s",
|
|
404
|
+
port,
|
|
405
|
+
error,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
def parse_discovery_response(
|
|
409
|
+
self,
|
|
410
|
+
buffer: bytes,
|
|
411
|
+
ip_address: str,
|
|
412
|
+
) -> DiscoveredPrinter | None:
|
|
413
|
+
"""Parse a UDP discovery response and return typed printer metadata."""
|
|
414
|
+
if not buffer:
|
|
186
415
|
return None
|
|
187
416
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
417
|
+
try:
|
|
418
|
+
if len(buffer) >= MODERN_PROTOCOL_SIZE:
|
|
419
|
+
return self.parse_modern_protocol(buffer, ip_address)
|
|
420
|
+
if len(buffer) >= LEGACY_PROTOCOL_SIZE:
|
|
421
|
+
return self.parse_legacy_protocol(buffer, ip_address)
|
|
422
|
+
|
|
423
|
+
logger.warning("Invalid discovery response: %s bytes from %s", len(buffer), ip_address)
|
|
424
|
+
return None
|
|
425
|
+
except Exception as error:
|
|
426
|
+
logger.error("Error parsing discovery response: %s", error)
|
|
427
|
+
return None
|
|
428
|
+
|
|
429
|
+
def parse_modern_protocol(self, buffer: bytes, ip_address: str) -> DiscoveredPrinter:
|
|
430
|
+
"""Parse a modern 276-byte (0x114) discovery response."""
|
|
431
|
+
if len(buffer) < MODERN_PROTOCOL_SIZE:
|
|
432
|
+
raise InvalidResponseError(len(buffer), ip_address)
|
|
433
|
+
|
|
434
|
+
name = buffer[0:0x84].decode("utf-8", errors="ignore").split("\x00", 1)[0]
|
|
435
|
+
command_port = int.from_bytes(buffer[0x84:0x86], byteorder="big")
|
|
436
|
+
vendor_id = int.from_bytes(buffer[0x86:0x88], byteorder="big")
|
|
437
|
+
product_id = int.from_bytes(buffer[0x88:0x8A], byteorder="big")
|
|
438
|
+
status_code = int.from_bytes(buffer[0x8A:0x8C], byteorder="big")
|
|
439
|
+
product_type = int.from_bytes(buffer[0x8C:0x8E], byteorder="big")
|
|
440
|
+
event_port = int.from_bytes(buffer[0x8E:0x90], byteorder="big")
|
|
441
|
+
serial_number = buffer[0x92 : 0x92 + 128].decode("utf-8", errors="ignore").split("\x00", 1)[0]
|
|
442
|
+
model = self.detect_modern_model(name, product_type)
|
|
443
|
+
|
|
444
|
+
return DiscoveredPrinter(
|
|
445
|
+
model=model,
|
|
446
|
+
protocol_format=DiscoveryProtocol.MODERN,
|
|
447
|
+
name=name,
|
|
448
|
+
ip_address=ip_address,
|
|
449
|
+
command_port=command_port,
|
|
450
|
+
serial_number=serial_number,
|
|
451
|
+
event_port=event_port,
|
|
452
|
+
vendor_id=vendor_id,
|
|
453
|
+
product_id=product_id,
|
|
454
|
+
product_type=product_type,
|
|
455
|
+
status_code=status_code,
|
|
456
|
+
status=self.map_status_code(status_code),
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
def parse_legacy_protocol(self, buffer: bytes, ip_address: str) -> DiscoveredPrinter:
|
|
460
|
+
"""Parse a legacy 140-byte discovery response."""
|
|
461
|
+
if len(buffer) < LEGACY_PROTOCOL_SIZE:
|
|
462
|
+
raise InvalidResponseError(len(buffer), ip_address)
|
|
463
|
+
|
|
464
|
+
name = buffer[0:0x80].decode("utf-8", errors="ignore").split("\x00", 1)[0]
|
|
465
|
+
command_port = int.from_bytes(buffer[0x84:0x86], byteorder="big")
|
|
466
|
+
vendor_id = int.from_bytes(buffer[0x86:0x88], byteorder="big")
|
|
467
|
+
product_id = int.from_bytes(buffer[0x88:0x8A], byteorder="big")
|
|
468
|
+
status_code = int.from_bytes(buffer[0x8A:0x8C], byteorder="big")
|
|
469
|
+
model = self.detect_legacy_model(name, product_id)
|
|
470
|
+
|
|
471
|
+
return DiscoveredPrinter(
|
|
472
|
+
model=model,
|
|
473
|
+
protocol_format=DiscoveryProtocol.LEGACY,
|
|
474
|
+
name=name,
|
|
475
|
+
ip_address=ip_address,
|
|
476
|
+
command_port=command_port,
|
|
477
|
+
vendor_id=vendor_id,
|
|
478
|
+
product_id=product_id,
|
|
479
|
+
status_code=status_code,
|
|
480
|
+
status=self.map_status_code(status_code),
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
def detect_modern_model(self, name: str, product_type: int) -> PrinterModel:
|
|
484
|
+
"""Detect a modern printer model from discovery metadata."""
|
|
485
|
+
upper_name = name.upper()
|
|
486
|
+
|
|
487
|
+
if upper_name == "AD5X":
|
|
488
|
+
return PrinterModel.AD5X
|
|
489
|
+
|
|
490
|
+
if product_type == 0x5A02:
|
|
491
|
+
if "PRO" in upper_name:
|
|
492
|
+
return PrinterModel.ADVENTURER_5M_PRO
|
|
493
|
+
return PrinterModel.ADVENTURER_5M
|
|
494
|
+
|
|
495
|
+
if "ADVENTURER 5M" in upper_name or "AD5M" in upper_name:
|
|
496
|
+
if "PRO" in upper_name:
|
|
497
|
+
return PrinterModel.ADVENTURER_5M_PRO
|
|
498
|
+
return PrinterModel.ADVENTURER_5M
|
|
499
|
+
|
|
500
|
+
return PrinterModel.UNKNOWN
|
|
501
|
+
|
|
502
|
+
def detect_legacy_model(self, name: str, product_id: int | None = None) -> PrinterModel:
|
|
503
|
+
"""Detect a legacy printer model from discovery metadata."""
|
|
504
|
+
upper_name = name.upper()
|
|
505
|
+
|
|
506
|
+
if "ADVENTURER 4" in upper_name or "ADVENTURER4" in upper_name or "AD4" in upper_name:
|
|
507
|
+
return PrinterModel.ADVENTURER_4
|
|
508
|
+
|
|
509
|
+
if "ADVENTURER 3" in upper_name or "ADVENTURER3" in upper_name or "AD3" in upper_name:
|
|
510
|
+
return PrinterModel.ADVENTURER_3
|
|
511
|
+
|
|
512
|
+
if product_id in {
|
|
513
|
+
LEGACY_PRODUCT_IDS["Adventurer4Lite"],
|
|
514
|
+
LEGACY_PRODUCT_IDS["Adventurer4Pro"],
|
|
515
|
+
}:
|
|
516
|
+
return PrinterModel.ADVENTURER_4
|
|
517
|
+
|
|
518
|
+
if product_id == LEGACY_PRODUCT_IDS["Adventurer3"]:
|
|
519
|
+
return PrinterModel.ADVENTURER_3
|
|
520
|
+
|
|
521
|
+
return PrinterModel.UNKNOWN
|
|
522
|
+
|
|
523
|
+
def map_status_code(self, status_code: int) -> PrinterStatus:
|
|
524
|
+
"""Map discovery status codes to the typed printer status enum."""
|
|
525
|
+
if status_code == 0:
|
|
526
|
+
return PrinterStatus.READY
|
|
527
|
+
if status_code == 1:
|
|
528
|
+
return PrinterStatus.BUSY
|
|
529
|
+
if status_code == 2:
|
|
530
|
+
return PrinterStatus.ERROR
|
|
531
|
+
return PrinterStatus.UNKNOWN
|
|
532
|
+
|
|
533
|
+
def get_broadcast_addresses(self) -> list[str]:
|
|
534
|
+
"""Retrieve broadcast addresses for all active IPv4 interfaces."""
|
|
535
|
+
broadcast_addresses: list[str] = []
|
|
196
536
|
|
|
197
537
|
try:
|
|
198
|
-
# Get all network interfaces
|
|
199
538
|
for interface_name in netifaces.interfaces():
|
|
200
539
|
try:
|
|
201
|
-
# Get IPv4 addresses for this interface
|
|
202
540
|
addresses = netifaces.ifaddresses(interface_name)
|
|
203
541
|
if netifaces.AF_INET not in addresses:
|
|
204
542
|
continue
|
|
205
543
|
|
|
206
544
|
for addr_info in addresses[netifaces.AF_INET]:
|
|
207
|
-
|
|
208
|
-
if addr_info.get('addr', '').startswith('127.'):
|
|
545
|
+
if addr_info.get("addr", "").startswith("127."):
|
|
209
546
|
continue
|
|
210
547
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if ip_addr and netmask:
|
|
216
|
-
broadcast = self._calculate_broadcast_address(ip_addr, netmask)
|
|
217
|
-
if broadcast and broadcast not in broadcast_addresses:
|
|
218
|
-
broadcast_addresses.append(broadcast)
|
|
219
|
-
logger.debug(f"Added broadcast address: {broadcast} (interface: {interface_name})")
|
|
220
|
-
|
|
221
|
-
except Exception as e:
|
|
222
|
-
logger.warning(f"Error processing interface {interface_name}: {e}")
|
|
223
|
-
continue
|
|
548
|
+
ip_addr = addr_info.get("addr")
|
|
549
|
+
netmask = addr_info.get("netmask")
|
|
550
|
+
if not ip_addr or not netmask:
|
|
551
|
+
continue
|
|
224
552
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
553
|
+
broadcast = self.calculate_broadcast_address(ip_addr, netmask)
|
|
554
|
+
if broadcast and broadcast not in broadcast_addresses:
|
|
555
|
+
broadcast_addresses.append(broadcast)
|
|
556
|
+
except Exception as error:
|
|
557
|
+
logger.warning("Error processing interface %s: %s", interface_name, error)
|
|
558
|
+
except Exception as error:
|
|
559
|
+
logger.error("Error getting network interfaces: %s", error)
|
|
560
|
+
broadcast_addresses = ["255.255.255.255", "192.168.1.255", "192.168.0.255"]
|
|
230
561
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
broadcast_addresses.append('255.255.255.255')
|
|
562
|
+
if "255.255.255.255" not in broadcast_addresses:
|
|
563
|
+
broadcast_addresses.append("255.255.255.255")
|
|
234
564
|
|
|
235
|
-
logger.debug(f"Using broadcast addresses: {broadcast_addresses}")
|
|
236
565
|
return broadcast_addresses
|
|
237
566
|
|
|
238
|
-
def
|
|
239
|
-
"""
|
|
240
|
-
Calculates the broadcast address for a given IP address and subnet mask.
|
|
241
|
-
|
|
242
|
-
Args:
|
|
243
|
-
ip_address: The IPv4 address string (e.g., "192.168.1.10")
|
|
244
|
-
subnet_mask: The IPv4 subnet mask string (e.g., "255.255.255.0")
|
|
245
|
-
|
|
246
|
-
Returns:
|
|
247
|
-
The calculated broadcast address string, or None if input is invalid
|
|
248
|
-
"""
|
|
567
|
+
def calculate_broadcast_address(self, ip_address: str, subnet_mask: str) -> str | None:
|
|
568
|
+
"""Calculate an IPv4 broadcast address from IP and subnet mask."""
|
|
249
569
|
try:
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
mask_parts = [int(x) for x in subnet_mask.split('.')]
|
|
253
|
-
|
|
570
|
+
ip_parts = [int(part) for part in ip_address.split(".")]
|
|
571
|
+
mask_parts = [int(part) for part in subnet_mask.split(".")]
|
|
254
572
|
if len(ip_parts) != 4 or len(mask_parts) != 4:
|
|
255
573
|
return None
|
|
574
|
+
broadcast_parts = [ip_parts[index] | (~mask_parts[index] & 255) for index in range(4)]
|
|
575
|
+
return ".".join(str(part) for part in broadcast_parts)
|
|
576
|
+
except Exception:
|
|
577
|
+
return None
|
|
256
578
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
579
|
+
def _join_multicast_group(self, transport: asyncio.DatagramTransport) -> None:
|
|
580
|
+
"""Join the discovery multicast group if the OS/network stack allows it."""
|
|
581
|
+
get_extra_info = getattr(transport, "get_extra_info", None)
|
|
582
|
+
if get_extra_info is None:
|
|
583
|
+
return
|
|
260
584
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
return
|
|
585
|
+
raw_socket = get_extra_info("socket")
|
|
586
|
+
if raw_socket is None:
|
|
587
|
+
return
|
|
264
588
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
print(f"Response length: {len(response)} bytes")
|
|
589
|
+
try:
|
|
590
|
+
membership = socket.inet_aton(MULTICAST_ADDRESS) + socket.inet_aton("0.0.0.0")
|
|
591
|
+
raw_socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, membership)
|
|
592
|
+
except Exception as error:
|
|
593
|
+
logger.warning(
|
|
594
|
+
"Discovery: Failed to join multicast group %s - %s",
|
|
595
|
+
MULTICAST_ADDRESS,
|
|
596
|
+
error,
|
|
597
|
+
)
|
|
275
598
|
|
|
276
|
-
# Hex dump
|
|
277
|
-
print("Hex dump:")
|
|
278
|
-
for i in range(0, len(response), 16):
|
|
279
|
-
line = f"{i:04x} "
|
|
280
599
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if i + j < len(response):
|
|
284
|
-
line += f"{response[i + j]:02x} "
|
|
285
|
-
else:
|
|
286
|
-
line += " "
|
|
600
|
+
class FlashForgePrinterDiscovery:
|
|
601
|
+
"""Backward-compatible wrapper around the universal PrinterDiscovery API."""
|
|
287
602
|
|
|
288
|
-
|
|
289
|
-
|
|
603
|
+
DISCOVERY_PORT = 48899
|
|
604
|
+
LISTEN_PORT = 18007
|
|
290
605
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
606
|
+
def __init__(self) -> None:
|
|
607
|
+
self.discovery_port = self.DISCOVERY_PORT
|
|
608
|
+
self.listen_port = self.LISTEN_PORT
|
|
609
|
+
self.discovery_message = bytes(
|
|
610
|
+
[
|
|
611
|
+
0x77,
|
|
612
|
+
0x77,
|
|
613
|
+
0x77,
|
|
614
|
+
0x2E,
|
|
615
|
+
0x75,
|
|
616
|
+
0x73,
|
|
617
|
+
0x72,
|
|
618
|
+
0x22,
|
|
619
|
+
0x65,
|
|
620
|
+
0x36,
|
|
621
|
+
0xC0,
|
|
622
|
+
0x00,
|
|
623
|
+
0x00,
|
|
624
|
+
0x00,
|
|
625
|
+
0x00,
|
|
626
|
+
0x00,
|
|
627
|
+
0x00,
|
|
628
|
+
0x00,
|
|
629
|
+
0x00,
|
|
630
|
+
0x00,
|
|
631
|
+
]
|
|
632
|
+
)
|
|
633
|
+
self._discovery = PrinterDiscovery()
|
|
297
634
|
|
|
298
|
-
|
|
635
|
+
async def discover_printers_async(
|
|
636
|
+
self,
|
|
637
|
+
timeout_ms: int = 10000,
|
|
638
|
+
idle_timeout_ms: int = 1500,
|
|
639
|
+
max_retries: int = 3,
|
|
640
|
+
) -> list[FlashForgePrinter]:
|
|
641
|
+
"""Discover printers and return the legacy FlashForgePrinter wrapper objects."""
|
|
642
|
+
printers = await self._discovery.discover(
|
|
643
|
+
DiscoveryOptions(
|
|
644
|
+
timeout=timeout_ms,
|
|
645
|
+
idle_timeout=idle_timeout_ms,
|
|
646
|
+
max_retries=max_retries,
|
|
647
|
+
)
|
|
648
|
+
)
|
|
649
|
+
return [FlashForgePrinter.from_discovered_printer(printer) for printer in printers]
|
|
299
650
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
651
|
+
def _parse_printer_response(
|
|
652
|
+
self,
|
|
653
|
+
response: bytes | None,
|
|
654
|
+
ip_address: str,
|
|
655
|
+
) -> FlashForgePrinter | None:
|
|
656
|
+
if response is None:
|
|
657
|
+
return None
|
|
307
658
|
|
|
659
|
+
if LEGACY_PROTOCOL_SIZE <= len(response) < MODERN_PROTOCOL_SIZE and len(response) >= 0x92:
|
|
660
|
+
legacy_name = response[0:32].decode("utf-8", errors="ignore").split("\x00", 1)[0].strip()
|
|
661
|
+
legacy_serial = (
|
|
662
|
+
response[0x92 : 0x92 + 32]
|
|
663
|
+
.decode("utf-8", errors="ignore")
|
|
664
|
+
.split("\x00", 1)[0]
|
|
665
|
+
.strip()
|
|
666
|
+
)
|
|
667
|
+
if legacy_name or legacy_serial:
|
|
668
|
+
return FlashForgePrinter(
|
|
669
|
+
name=legacy_name,
|
|
670
|
+
serial_number=legacy_serial,
|
|
671
|
+
ip_address=ip_address,
|
|
672
|
+
)
|
|
308
673
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
674
|
+
printer = self._discovery.parse_discovery_response(response, ip_address)
|
|
675
|
+
if printer is None:
|
|
676
|
+
return None
|
|
677
|
+
if not printer.name and not printer.serial_number:
|
|
678
|
+
return None
|
|
679
|
+
return FlashForgePrinter.from_discovered_printer(printer)
|
|
314
680
|
|
|
315
|
-
def
|
|
316
|
-
self.
|
|
317
|
-
self.printers: List[FlashForgePrinter] = []
|
|
318
|
-
self.response_event = asyncio.Event()
|
|
319
|
-
self.last_response_time = 0.0
|
|
320
|
-
|
|
321
|
-
def connection_made(self, transport):
|
|
322
|
-
self.transport = transport
|
|
323
|
-
logger.debug("Discovery protocol connection established")
|
|
324
|
-
|
|
325
|
-
def datagram_received(self, data: bytes, addr):
|
|
326
|
-
"""Handle incoming UDP datagram (printer response)."""
|
|
327
|
-
ip_address = addr[0]
|
|
328
|
-
logger.debug(f"Received {len(data)} bytes from {ip_address}")
|
|
329
|
-
|
|
330
|
-
# Parse the response
|
|
331
|
-
printer = self.discovery._parse_printer_response(data, ip_address)
|
|
332
|
-
if printer:
|
|
333
|
-
self.printers.append(printer)
|
|
334
|
-
logger.info(f"Discovered printer: {printer}")
|
|
335
|
-
|
|
336
|
-
# Update last response time and signal that we got a response
|
|
337
|
-
self.last_response_time = asyncio.get_event_loop().time()
|
|
338
|
-
self.response_event.set()
|
|
339
|
-
self.response_event.clear() # Reset for next response
|
|
340
|
-
|
|
341
|
-
def error_received(self, exc):
|
|
342
|
-
logger.error(f"Discovery protocol error: {exc}")
|
|
343
|
-
|
|
344
|
-
async def wait_for_responses(self, total_timeout: float, idle_timeout: float) -> List[FlashForgePrinter]:
|
|
345
|
-
"""
|
|
346
|
-
Wait for printer responses with total and idle timeouts.
|
|
347
|
-
|
|
348
|
-
Args:
|
|
349
|
-
total_timeout: Maximum total time to wait for responses
|
|
350
|
-
idle_timeout: Maximum time to wait between responses
|
|
351
|
-
|
|
352
|
-
Returns:
|
|
353
|
-
List of discovered printers
|
|
354
|
-
"""
|
|
355
|
-
start_time = asyncio.get_event_loop().time()
|
|
356
|
-
self.last_response_time = start_time
|
|
357
|
-
|
|
358
|
-
logger.debug(f"Waiting for responses (total timeout: {total_timeout}s, idle timeout: {idle_timeout}s)")
|
|
681
|
+
def _get_broadcast_addresses(self) -> list[str]:
|
|
682
|
+
return self._discovery.get_broadcast_addresses()
|
|
359
683
|
|
|
360
|
-
|
|
361
|
-
|
|
684
|
+
def _calculate_broadcast_address(self, ip_address: str, subnet_mask: str) -> str | None:
|
|
685
|
+
return self._discovery.calculate_broadcast_address(ip_address, subnet_mask)
|
|
362
686
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
687
|
+
def print_debug_info(self, response: bytes, ip_address: str) -> None:
|
|
688
|
+
print(f"Received response from {ip_address}:")
|
|
689
|
+
print(f"Response length: {len(response)} bytes")
|
|
690
|
+
print("Hex dump:")
|
|
691
|
+
for index in range(0, len(response), 16):
|
|
692
|
+
parts = [f"{index:04x} "]
|
|
693
|
+
for offset in range(16):
|
|
694
|
+
if index + offset < len(response):
|
|
695
|
+
parts.append(f"{response[index + offset]:02x} ")
|
|
696
|
+
else:
|
|
697
|
+
parts.append(" ")
|
|
698
|
+
if offset == 7:
|
|
699
|
+
parts.append(" ")
|
|
367
700
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
701
|
+
parts.append(" ")
|
|
702
|
+
for offset in range(16):
|
|
703
|
+
if index + offset < len(response):
|
|
704
|
+
byte = response[index + offset]
|
|
705
|
+
parts.append(chr(byte) if 32 <= byte <= 126 else ".")
|
|
706
|
+
print("".join(parts))
|
|
707
|
+
|
|
708
|
+
print("ASCII dump:")
|
|
709
|
+
print(repr(response.decode("ascii", errors="replace")))
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
async def _async_main() -> None:
|
|
713
|
+
discovery = FlashForgePrinterDiscovery()
|
|
714
|
+
printers = await discovery.discover_printers_async()
|
|
715
|
+
for printer in printers:
|
|
716
|
+
print(printer)
|
|
372
717
|
|
|
373
|
-
# Wait for next response or timeout
|
|
374
|
-
try:
|
|
375
|
-
remaining_total = total_timeout - (current_time - start_time)
|
|
376
|
-
remaining_idle = idle_timeout - (current_time - self.last_response_time)
|
|
377
|
-
wait_time = min(remaining_total, remaining_idle, 0.1) # Max 100ms wait
|
|
378
|
-
|
|
379
|
-
if wait_time <= 0:
|
|
380
|
-
break
|
|
381
|
-
|
|
382
|
-
await asyncio.wait_for(self.response_event.wait(), timeout=wait_time)
|
|
383
|
-
except asyncio.TimeoutError:
|
|
384
|
-
# Continue to check timeouts
|
|
385
|
-
continue
|
|
386
718
|
|
|
387
|
-
|
|
388
|
-
|
|
719
|
+
def main() -> None:
|
|
720
|
+
"""CLI entry point for `flashforge-discover`."""
|
|
721
|
+
asyncio.run(_async_main())
|