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