flashforge-python-api 1.0.2__py3-none-any.whl → 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. flashforge/__init__.py +62 -29
  2. flashforge/api/__init__.py +1 -0
  3. flashforge/api/constants/__init__.py +1 -0
  4. flashforge/api/constants/commands.py +1 -0
  5. flashforge/api/constants/endpoints.py +1 -0
  6. flashforge/api/controls/__init__.py +1 -0
  7. flashforge/api/controls/control.py +64 -61
  8. flashforge/api/controls/files.py +28 -26
  9. flashforge/api/controls/info.py +84 -85
  10. flashforge/api/controls/job_control.py +120 -82
  11. flashforge/api/controls/temp_control.py +14 -11
  12. flashforge/api/misc/__init__.py +1 -1
  13. flashforge/api/network/__init__.py +2 -1
  14. flashforge/api/network/utils.py +7 -8
  15. flashforge/client.py +131 -63
  16. flashforge/discovery/__init__.py +30 -3
  17. flashforge/discovery/discovery.py +637 -308
  18. flashforge/models/__init__.py +11 -10
  19. flashforge/models/machine_info.py +220 -100
  20. flashforge/models/responses.py +103 -43
  21. flashforge/tcp/__init__.py +25 -17
  22. flashforge/tcp/a3_client.py +405 -0
  23. flashforge/tcp/ff_client.py +93 -90
  24. flashforge/tcp/gcode/__init__.py +4 -2
  25. flashforge/tcp/gcode/a3_gcode_controller.py +109 -0
  26. flashforge/tcp/gcode/gcode_controller.py +48 -36
  27. flashforge/tcp/gcode/gcodes.py +7 -1
  28. flashforge/tcp/parsers/__init__.py +11 -11
  29. flashforge/tcp/parsers/endstop_status.py +103 -132
  30. flashforge/tcp/parsers/location_info.py +26 -13
  31. flashforge/tcp/parsers/print_status.py +49 -56
  32. flashforge/tcp/parsers/printer_info.py +38 -48
  33. flashforge/tcp/parsers/temp_info.py +46 -47
  34. flashforge/tcp/parsers/thumbnail_info.py +65 -40
  35. flashforge/tcp/tcp_client.py +296 -293
  36. {flashforge_python_api-1.0.2.dist-info → flashforge_python_api-1.1.0.dist-info}/METADATA +35 -14
  37. flashforge_python_api-1.1.0.dist-info/RECORD +45 -0
  38. {flashforge_python_api-1.0.2.dist-info → flashforge_python_api-1.1.0.dist-info}/WHEEL +1 -1
  39. flashforge_python_api-1.0.2.dist-info/RECORD +0 -43
  40. {flashforge_python_api-1.0.2.dist-info → flashforge_python_api-1.1.0.dist-info}/entry_points.txt +0 -0
  41. {flashforge_python_api-1.0.2.dist-info → flashforge_python_api-1.1.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,388 +1,717 @@
1
1
  """
2
- FlashForge Python API - Printer Discovery (FIXED VERSION)
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 dataclasses import dataclass
11
- from typing import List, Optional
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
- @dataclass
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
- Represents a discovered FlashForge 3D printer.
22
- Stores information such as name, serial number, and IP address.
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
- """Returns a detailed string representation for debugging."""
34
- return f"FlashForgePrinter(name='{self.name}', serial_number='{self.serial_number}', ip_address='{self.ip_address}')"
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 FlashForgePrinterDiscovery:
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
- # The UDP port used for sending discovery messages to FlashForge printers
44
- DISCOVERY_PORT = 48899
45
- # The port we listen on for responses (must match TypeScript implementation)
46
- LISTEN_PORT = 18007
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
- def __init__(self):
49
- """Initialize the printer discovery client."""
50
- self.discovery_port = self.DISCOVERY_PORT
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
- # The discovery UDP packet is a 20-byte message.
54
- # It starts with "www.usr" followed by specific bytes.
55
- # This packet structure is based on observations from FlashPrint software.
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
- async def discover_printers_async(
63
- self,
64
- timeout_ms: int = 10000,
65
- idle_timeout_ms: int = 1500,
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
- try:
93
- # Use asyncio datagram endpoint for proper UDP handling
94
- transport, protocol = await asyncio.get_event_loop().create_datagram_endpoint(
95
- lambda: DiscoveryProtocol(self),
96
- local_addr=('0.0.0.0', self.listen_port),
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
- # Send discovery messages to all broadcast addresses
102
- for broadcast_address in broadcast_addresses:
103
- try:
104
- transport.sendto(self.discovery_message, (broadcast_address, self.discovery_port))
105
- logger.debug(f"Sent discovery message to {broadcast_address}:{self.discovery_port}")
106
- except Exception as e:
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
- finally:
116
- transport.close()
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 # Printers found, exit retry loop
120
-
121
- if attempt < max_retries:
122
- logger.debug(f"No printers found, waiting before retry {attempt + 1}")
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
- if len(response) < 0xC4:
162
- logger.warning(f"Invalid response from {ip_address}, length: {len(response)}")
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
- # Printer name is at offset 0x00, padded with null characters
167
- name = response[0:32].decode('ascii', errors='ignore').rstrip('\x00')
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
- # Serial number is at offset 0x92, padded with null characters
170
- serial_number = response[0x92:0x92 + 32].decode('ascii', errors='ignore').rstrip('\x00')
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
- if not name and not serial_number:
173
- logger.warning(f"Empty name and serial from {ip_address}")
174
- return None
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
- printer = FlashForgePrinter(
177
- name=name,
178
- serial_number=serial_number,
179
- ip_address=ip_address
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
- return printer
360
+ return printers
183
361
 
184
- except Exception as e:
185
- logger.error(f"Error parsing response from {ip_address}: {e}")
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
- def _get_broadcast_addresses(self) -> List[str]:
189
- """
190
- Retrieves a list of broadcast addresses for all active IPv4 network interfaces.
191
-
192
- Returns:
193
- A list of broadcast address strings
194
- """
195
- broadcast_addresses: List[str] = []
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
- # Skip loopback interfaces
208
- if addr_info.get('addr', '').startswith('127.'):
541
+ if addr_info.get("addr", "").startswith("127."):
209
542
  continue
210
543
 
211
- # Calculate broadcast address if netmask is available
212
- ip_addr = addr_info.get('addr')
213
- netmask = addr_info.get('netmask')
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
- except Exception as e:
226
- logger.error(f"Error getting network interfaces: {e}")
227
- # Fallback to common broadcast addresses
228
- broadcast_addresses = ['255.255.255.255', '192.168.1.255', '192.168.0.255']
229
- logger.info("Using fallback broadcast addresses")
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
- # Always include the general broadcast address
232
- if '255.255.255.255' not in broadcast_addresses:
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 _calculate_broadcast_address(self, ip_address: str, subnet_mask: str) -> Optional[str]:
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
- # Convert IP and subnet to arrays of numbers
251
- ip_parts = [int(x) for x in ip_address.split('.')]
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
- # Calculate broadcast address: IP | (~MASK)
258
- broadcast_parts = [ip_parts[i] | (~mask_parts[i] & 255) for i in range(4)]
259
- return '.'.join(map(str, broadcast_parts))
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
- except Exception as e:
262
- logger.warning(f"Error calculating broadcast address for {ip_address}/{subnet_mask}: {e}")
263
- return None
581
+ raw_socket = get_extra_info("socket")
582
+ if raw_socket is None:
583
+ return
264
584
 
265
- def print_debug_info(self, response: bytes, ip_address: str) -> None:
266
- """
267
- Prints detailed debugging information about a received UDP response.
268
-
269
- Args:
270
- response: The bytes containing the response data
271
- ip_address: The IP address from which the response was received
272
- """
273
- print(f"Received response from {ip_address}:")
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
- # Hex values
282
- for j in range(16):
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
- if j == 7:
289
- line += " "
599
+ DISCOVERY_PORT = 48899
600
+ LISTEN_PORT = 18007
290
601
 
291
- # ASCII representation
292
- line += " "
293
- for j in range(16):
294
- if i + j < len(response):
295
- c = response[i + j]
296
- line += chr(c) if 32 <= c <= 126 else '.'
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
- print(line)
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
- # ASCII dump
301
- print("ASCII dump:")
302
- try:
303
- ascii_content = response.decode('ascii', errors='replace')
304
- print(repr(ascii_content))
305
- except Exception as e:
306
- print(f"Error decoding ASCII: {e}")
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
- class DiscoveryProtocol(asyncio.DatagramProtocol):
310
- """
311
- Asyncio datagram protocol for handling UDP discovery responses.
312
- This matches the event-driven approach used in the TypeScript implementation.
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 __init__(self, discovery: FlashForgePrinterDiscovery):
316
- self.discovery = discovery
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
- while True:
361
- current_time = asyncio.get_event_loop().time()
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
- # Check total timeout
364
- if current_time - start_time >= total_timeout:
365
- logger.debug("Total timeout reached")
366
- break
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
- # Check idle timeout
369
- if current_time - self.last_response_time >= idle_timeout:
370
- logger.debug("Idle timeout reached")
371
- break
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
- logger.debug(f"Finished waiting, found {len(self.printers)} printers")
388
- return self.printers
715
+ def main() -> None:
716
+ """CLI entry point for `flashforge-discover`."""
717
+ asyncio.run(_async_main())