ssdp_server.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. """SSDP discovery responder for virtual printer.
  2. Responds to M-SEARCH requests from slicers and sends periodic NOTIFY
  3. announcements so the virtual printer appears as a discoverable Bambu printer.
  4. """
  5. import asyncio
  6. import logging
  7. import socket
  8. import struct
  9. from datetime import datetime
  10. logger = logging.getLogger(__name__)
  11. # SSDP multicast address - Bambu uses port 2021
  12. SSDP_ADDR = "239.255.255.250"
  13. SSDP_PORT = 2021
  14. # Bambu service target
  15. BAMBU_SEARCH_TARGET = "urn:bambulab-com:device:3dprinter:1"
  16. class VirtualPrinterSSDPServer:
  17. """SSDP server that responds to discovery requests as a virtual Bambu printer."""
  18. def __init__(
  19. self,
  20. name: str = "Bambuddy",
  21. serial: str = "00M09A391800001", # X1C serial format for compatibility
  22. model: str = "BL-P001", # X1C model code for best compatibility
  23. ):
  24. """Initialize the SSDP server.
  25. Args:
  26. name: Display name shown in slicer discovery
  27. serial: Unique serial number for this virtual printer (must match cert CN)
  28. model: Model code (BL-P001=X1C, C11=P1S, O1D=H2D)
  29. """
  30. self.name = name
  31. self.serial = serial
  32. self.model = model
  33. self._running = False
  34. self._socket: socket.socket | None = None
  35. self._local_ip: str | None = None
  36. def _get_local_ip(self) -> str:
  37. """Get the local IP address to advertise."""
  38. if self._local_ip:
  39. return self._local_ip
  40. # Try to determine local IP by connecting to a public address
  41. try:
  42. s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  43. s.connect(("8.8.8.8", 80))
  44. ip = s.getsockname()[0]
  45. s.close()
  46. self._local_ip = ip
  47. return ip
  48. except Exception:
  49. return "127.0.0.1"
  50. def _build_notify_message(self) -> bytes:
  51. """Build SSDP NOTIFY message for periodic announcements."""
  52. ip = self._get_local_ip()
  53. # Based on: https://gist.github.com/Alex-Schaefer/72a9e2491a42da2ef99fb87601955cc3
  54. # Key: DevBind.bambu.com: free - tells slicer printer is NOT cloud-bound
  55. message = (
  56. "NOTIFY * HTTP/1.1\r\n"
  57. f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n"
  58. "Server: Buildroot/2018.02-rc3 UPnP/1.0 ssdpd/1.8\r\n"
  59. "Cache-Control: max-age=1800\r\n"
  60. f"Location: {ip}\r\n"
  61. f"NT: {BAMBU_SEARCH_TARGET}\r\n"
  62. "NTS: ssdp:alive\r\n"
  63. "EXT:\r\n"
  64. f"USN: {self.serial}\r\n"
  65. f"DevModel.bambu.com: {self.model}\r\n"
  66. f"DevName.bambu.com: {self.name}\r\n"
  67. "DevSignal.bambu.com: -44\r\n"
  68. "DevConnect.bambu.com: lan\r\n"
  69. "DevBind.bambu.com: free\r\n"
  70. "Devseclink.bambu.com: secure\r\n"
  71. "DevVersion.bambu.com: 01.07.00.00\r\n"
  72. "\r\n"
  73. )
  74. return message.encode()
  75. def _build_response_message(self) -> bytes:
  76. """Build SSDP response message for M-SEARCH requests."""
  77. ip = self._get_local_ip()
  78. # Based on: https://gist.github.com/Alex-Schaefer/72a9e2491a42da2ef99fb87601955cc3
  79. # Key: DevBind.bambu.com: free - tells slicer printer is NOT cloud-bound
  80. # Added: Devseclink, DevVersion, DevCap for better compatibility
  81. message = (
  82. "HTTP/1.1 200 OK\r\n"
  83. "Server: Buildroot/2018.02-rc3 UPnP/1.0 ssdpd/1.8\r\n"
  84. f"Date: {datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT')}\r\n"
  85. f"Location: {ip}\r\n"
  86. f"ST: {BAMBU_SEARCH_TARGET}\r\n"
  87. "EXT:\r\n"
  88. f"USN: {self.serial}\r\n"
  89. "Cache-Control: max-age=1800\r\n"
  90. f"DevModel.bambu.com: {self.model}\r\n"
  91. f"DevName.bambu.com: {self.name}\r\n"
  92. "DevSignal.bambu.com: -44\r\n"
  93. "DevConnect.bambu.com: lan\r\n"
  94. "DevBind.bambu.com: free\r\n"
  95. "Devseclink.bambu.com: secure\r\n"
  96. "DevVersion.bambu.com: 01.07.00.00\r\n"
  97. "\r\n"
  98. )
  99. return message.encode()
  100. async def start(self) -> None:
  101. """Start the SSDP server."""
  102. if self._running:
  103. return
  104. logger.info(f"Starting virtual printer SSDP server: {self.name} ({self.serial})")
  105. self._running = True
  106. try:
  107. # Create UDP socket
  108. self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
  109. self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  110. # Try to set SO_REUSEPORT if available
  111. try:
  112. self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
  113. except (AttributeError, OSError):
  114. pass
  115. # Set non-blocking mode
  116. self._socket.setblocking(False)
  117. # Bind to SSDP port
  118. self._socket.bind(("", SSDP_PORT))
  119. # Join multicast group
  120. mreq = struct.pack("4sl", socket.inet_aton(SSDP_ADDR), socket.INADDR_ANY)
  121. self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
  122. # Enable broadcast
  123. self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
  124. # Set multicast TTL
  125. self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
  126. local_ip = self._get_local_ip()
  127. logger.info(f"SSDP server listening on port {SSDP_PORT}, advertising IP: {local_ip}")
  128. logger.info(f"Virtual printer: {self.name} ({self.serial}) model={self.model}")
  129. # Send initial NOTIFY
  130. await self._send_notify()
  131. logger.info("Sent initial SSDP NOTIFY announcement")
  132. # Run receive and announce loops
  133. last_notify = asyncio.get_event_loop().time()
  134. notify_interval = 30.0 # Send NOTIFY every 30 seconds
  135. while self._running:
  136. # Try to receive M-SEARCH requests
  137. try:
  138. data, addr = self._socket.recvfrom(4096)
  139. message = data.decode("utf-8", errors="ignore")
  140. await self._handle_message(message, addr)
  141. except BlockingIOError:
  142. pass
  143. except Exception as e:
  144. if self._running:
  145. logger.debug(f"SSDP receive error: {e}")
  146. # Send periodic NOTIFY
  147. now = asyncio.get_event_loop().time()
  148. if now - last_notify >= notify_interval:
  149. await self._send_notify()
  150. last_notify = now
  151. await asyncio.sleep(0.1)
  152. except OSError as e:
  153. if e.errno == 98: # Address already in use
  154. logger.warning(f"SSDP port {SSDP_PORT} in use - real printers may be running")
  155. else:
  156. logger.error(f"SSDP server error: {e}")
  157. except asyncio.CancelledError:
  158. logger.debug("SSDP server cancelled")
  159. except Exception as e:
  160. logger.error(f"SSDP server error: {e}")
  161. finally:
  162. await self._cleanup()
  163. async def stop(self) -> None:
  164. """Stop the SSDP server."""
  165. logger.info("Stopping SSDP server")
  166. self._running = False
  167. await self._cleanup()
  168. async def _cleanup(self) -> None:
  169. """Clean up resources."""
  170. if self._socket:
  171. try:
  172. # Send byebye message
  173. await self._send_byebye()
  174. except Exception:
  175. pass
  176. try:
  177. self._socket.close()
  178. except Exception:
  179. pass
  180. self._socket = None
  181. async def _send_notify(self) -> None:
  182. """Send SSDP NOTIFY message."""
  183. if not self._socket:
  184. return
  185. try:
  186. msg = self._build_notify_message()
  187. self._socket.sendto(msg, (SSDP_ADDR, SSDP_PORT))
  188. logger.debug(f"Sent SSDP NOTIFY for {self.name}")
  189. except Exception as e:
  190. logger.debug(f"Failed to send NOTIFY: {e}")
  191. async def _send_byebye(self) -> None:
  192. """Send SSDP byebye message when shutting down."""
  193. if not self._socket:
  194. return
  195. message = (
  196. "NOTIFY * HTTP/1.1\r\n"
  197. f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n"
  198. f"NT: {BAMBU_SEARCH_TARGET}\r\n"
  199. "NTS: ssdp:byebye\r\n"
  200. f"USN: {self.serial}\r\n"
  201. "\r\n"
  202. )
  203. try:
  204. self._socket.sendto(message.encode(), (SSDP_ADDR, SSDP_PORT))
  205. logger.debug("Sent SSDP byebye")
  206. except Exception:
  207. pass
  208. async def _handle_message(self, message: str, addr: tuple[str, int]) -> None:
  209. """Handle incoming SSDP message.
  210. Args:
  211. message: The SSDP message content
  212. addr: Tuple of (ip_address, port) of sender
  213. """
  214. # Check if this is an M-SEARCH request for Bambu printers
  215. if "M-SEARCH" not in message:
  216. return
  217. # Check search target
  218. if BAMBU_SEARCH_TARGET not in message and "ssdp:all" not in message.lower():
  219. return
  220. logger.debug(f"Received M-SEARCH from {addr[0]}")
  221. # Send response
  222. if self._socket:
  223. try:
  224. response = self._build_response_message()
  225. self._socket.sendto(response, addr)
  226. logger.info(f"Sent SSDP response to {addr[0]} for virtual printer '{self.name}'")
  227. except Exception as e:
  228. logger.debug(f"Failed to send SSDP response: {e}")