discovery.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. """
  2. Bambu Lab printer discovery service using SSDP.
  3. Bambu Lab printers advertise themselves via SSDP (Simple Service Discovery Protocol)
  4. on the local network. This service listens for these advertisements and provides
  5. a list of discovered printers.
  6. """
  7. import asyncio
  8. import logging
  9. import re
  10. import socket
  11. import struct
  12. from dataclasses import dataclass
  13. from datetime import datetime
  14. logger = logging.getLogger(__name__)
  15. # SSDP multicast address - Bambu uses port 2021, not standard 1900
  16. SSDP_ADDR = "239.255.255.250"
  17. SSDP_PORT = 2021 # Bambu Lab uses non-standard port
  18. # Bambu Lab SSDP search target
  19. BAMBU_SEARCH_TARGET = "urn:bambulab-com:device:3dprinter:1"
  20. # SSDP M-SEARCH message
  21. SSDP_MSEARCH = (
  22. "M-SEARCH * HTTP/1.1\r\n"
  23. f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n"
  24. 'MAN: "ssdp:discover"\r\n'
  25. "MX: 3\r\n"
  26. f"ST: {BAMBU_SEARCH_TARGET}\r\n"
  27. "\r\n"
  28. )
  29. @dataclass
  30. class DiscoveredPrinter:
  31. """Represents a discovered Bambu Lab printer."""
  32. serial: str
  33. name: str
  34. ip_address: str
  35. model: str | None = None
  36. discovered_at: str | None = None
  37. def to_dict(self) -> dict:
  38. return {
  39. "serial": self.serial,
  40. "name": self.name,
  41. "ip_address": self.ip_address,
  42. "model": self.model,
  43. "discovered_at": self.discovered_at,
  44. }
  45. class PrinterDiscoveryService:
  46. """Service for discovering Bambu Lab printers on the network."""
  47. def __init__(self):
  48. self._discovered: dict[str, DiscoveredPrinter] = {}
  49. self._running = False
  50. self._task: asyncio.Task | None = None
  51. @property
  52. def is_running(self) -> bool:
  53. return self._running
  54. @property
  55. def discovered_printers(self) -> list[DiscoveredPrinter]:
  56. return list(self._discovered.values())
  57. def clear(self):
  58. """Clear discovered printers."""
  59. self._discovered.clear()
  60. async def start(self, duration: float = 10.0):
  61. """Start discovery for a specified duration."""
  62. if self._running:
  63. return
  64. self._running = True
  65. self._discovered.clear()
  66. self._task = asyncio.create_task(self._discover(duration))
  67. async def stop(self):
  68. """Stop discovery."""
  69. self._running = False
  70. if self._task and not self._task.done():
  71. self._task.cancel()
  72. try:
  73. await self._task
  74. except asyncio.CancelledError:
  75. pass
  76. self._task = None
  77. async def _discover(self, duration: float):
  78. """Run discovery for the specified duration.
  79. Bambu printers broadcast NOTIFY messages periodically on port 2021.
  80. We need to bind to that port and listen for broadcasts.
  81. """
  82. sock = None
  83. try:
  84. # Create UDP socket for SSDP
  85. sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
  86. sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  87. # Try to set SO_REUSEPORT if available (Linux/macOS)
  88. try:
  89. sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
  90. except (AttributeError, OSError):
  91. pass
  92. # Set non-blocking mode
  93. sock.setblocking(False)
  94. # Bind to the SSDP port to receive NOTIFY broadcasts from printers
  95. sock.bind(("", SSDP_PORT))
  96. # Join multicast group to receive multicast messages
  97. mreq = struct.pack("4sl", socket.inet_aton(SSDP_ADDR), socket.INADDR_ANY)
  98. sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
  99. # Enable broadcast
  100. sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
  101. logger.info(f"Starting SSDP discovery on port {SSDP_PORT} for Bambu Lab printers...")
  102. # Send initial M-SEARCH request to trigger responses
  103. try:
  104. sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT))
  105. except Exception as e:
  106. logger.debug(f"M-SEARCH send error: {e}")
  107. start_time = asyncio.get_event_loop().time()
  108. last_send = start_time
  109. while self._running and (asyncio.get_event_loop().time() - start_time) < duration:
  110. # Try to receive data
  111. try:
  112. data, addr = sock.recvfrom(4096)
  113. message = data.decode("utf-8", errors="ignore")
  114. logger.debug(f"Received from {addr[0]}: {message[:100]}...")
  115. self._handle_response(message, addr[0])
  116. except BlockingIOError:
  117. # No data available, that's fine
  118. pass
  119. except Exception as e:
  120. logger.debug(f"SSDP receive error: {e}")
  121. # Re-send M-SEARCH every 3 seconds
  122. now = asyncio.get_event_loop().time()
  123. if now - last_send >= 3.0:
  124. try:
  125. sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT))
  126. last_send = now
  127. except Exception as e:
  128. logger.debug(f"SSDP send error: {e}")
  129. await asyncio.sleep(0.1)
  130. logger.info(f"Discovery complete. Found {len(self._discovered)} printers.")
  131. except OSError as e:
  132. if e.errno == 98: # Address already in use
  133. logger.warning(f"Port {SSDP_PORT} is in use, trying alternative discovery...")
  134. await self._discover_alternative(duration)
  135. else:
  136. logger.error(f"Discovery error: {e}")
  137. except Exception as e:
  138. logger.error(f"Discovery error: {e}")
  139. finally:
  140. self._running = False
  141. if sock:
  142. try:
  143. sock.close()
  144. except Exception:
  145. pass
  146. async def _discover_alternative(self, duration: float):
  147. """Alternative discovery using a random port (less reliable)."""
  148. sock = None
  149. try:
  150. sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
  151. sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  152. sock.setblocking(False)
  153. sock.bind(("", 0))
  154. # Join multicast group
  155. mreq = struct.pack("4sl", socket.inet_aton(SSDP_ADDR), socket.INADDR_ANY)
  156. sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
  157. sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
  158. logger.info("Using alternative discovery method...")
  159. start_time = asyncio.get_event_loop().time()
  160. last_send = start_time
  161. while self._running and (asyncio.get_event_loop().time() - start_time) < duration:
  162. try:
  163. data, addr = sock.recvfrom(4096)
  164. self._handle_response(data.decode("utf-8", errors="ignore"), addr[0])
  165. except BlockingIOError:
  166. pass
  167. except Exception as e:
  168. logger.debug(f"SSDP receive error: {e}")
  169. now = asyncio.get_event_loop().time()
  170. if now - last_send >= 2.0:
  171. try:
  172. sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT))
  173. last_send = now
  174. except Exception:
  175. pass
  176. await asyncio.sleep(0.1)
  177. logger.info(f"Alternative discovery complete. Found {len(self._discovered)} printers.")
  178. except Exception as e:
  179. logger.error(f"Alternative discovery error: {e}")
  180. finally:
  181. if sock:
  182. try:
  183. sock.close()
  184. except Exception:
  185. pass
  186. def _handle_response(self, response: str, ip_address: str):
  187. """Parse SSDP response and extract printer info."""
  188. # Check if it's a Bambu Lab printer response
  189. if BAMBU_SEARCH_TARGET not in response and "bambulab" not in response.lower():
  190. logger.debug(f"Ignoring non-Bambu response from {ip_address}")
  191. return
  192. # Extract USN (Unique Service Name) which contains the serial
  193. # Bambu format is just "USN: SERIALNUMBER" (no uuid: prefix)
  194. usn_match = re.search(r"USN:\s*(?:uuid:)?([^\s\r\n]+)", response, re.IGNORECASE)
  195. if not usn_match:
  196. logger.debug(f"No USN found in response from {ip_address}")
  197. return
  198. serial = usn_match.group(1).strip()
  199. # Extract device name from LOCATION or DevName header
  200. name = serial # Default to serial if no name found
  201. name_match = re.search(r"DevName\.bambu\.com:\s*(.+?)(?:\r\n|\n|$)", response, re.IGNORECASE)
  202. if name_match:
  203. name = name_match.group(1).strip()
  204. # Try to extract model from DevModel header
  205. model = None
  206. model_match = re.search(r"DevModel\.bambu\.com:\s*(.+?)(?:\r\n|\n|$)", response, re.IGNORECASE)
  207. if model_match:
  208. model = model_match.group(1).strip()
  209. # Also try NT header for model
  210. if not model:
  211. nt_match = re.search(r"NT:\s*urn:bambulab-com:device:([^:]+)", response, re.IGNORECASE)
  212. if nt_match:
  213. model = nt_match.group(1).strip()
  214. # Skip if already discovered
  215. if serial in self._discovered:
  216. return
  217. printer = DiscoveredPrinter(
  218. serial=serial,
  219. name=name,
  220. ip_address=ip_address,
  221. model=model,
  222. discovered_at=datetime.now().isoformat(),
  223. )
  224. self._discovered[serial] = printer
  225. logger.info(f"Discovered printer: {name} ({serial}) at {ip_address}")
  226. # Global discovery service instance
  227. discovery_service = PrinterDiscoveryService()