ssdp_server.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  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. Also provides SSDP proxy functionality for proxy mode, where Bambuddy sits
  5. between two networks and re-broadcasts printer SSDP from LAN A to LAN B.
  6. """
  7. import asyncio
  8. import logging
  9. import re
  10. import socket
  11. import struct
  12. logger = logging.getLogger(__name__)
  13. # SSDP addresses - Bambu uses port 2021
  14. # Real Bambu printers broadcast to 255.255.255.255, not multicast to 239.255.255.250
  15. SSDP_MULTICAST_ADDR = "239.255.255.250"
  16. SSDP_BROADCAST_ADDR = "255.255.255.255"
  17. SSDP_PORT = 2021
  18. # Bambu service target
  19. BAMBU_SEARCH_TARGET = "urn:bambulab-com:device:3dprinter:1"
  20. class VirtualPrinterSSDPServer:
  21. """SSDP server that responds to discovery requests as a virtual Bambu printer."""
  22. def __init__(
  23. self,
  24. name: str = "Bambuddy",
  25. serial: str = "00M09A391800001", # X1C serial format for compatibility
  26. model: str = "BL-P001", # X1C model code for best compatibility
  27. ):
  28. """Initialize the SSDP server.
  29. Args:
  30. name: Display name shown in slicer discovery
  31. serial: Unique serial number for this virtual printer (must match cert CN)
  32. model: Model code (BL-P001=X1C, C11=P1S, O1D=H2D)
  33. """
  34. self.name = name
  35. self.serial = serial
  36. self.model = model
  37. self._running = False
  38. self._socket: socket.socket | None = None
  39. self._local_ip: str | None = None
  40. def _get_local_ip(self) -> str:
  41. """Get the local IP address to advertise."""
  42. if self._local_ip:
  43. return self._local_ip
  44. # Try to determine local IP by connecting to a public address
  45. try:
  46. s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  47. s.connect(("8.8.8.8", 80))
  48. ip = s.getsockname()[0]
  49. s.close()
  50. self._local_ip = ip
  51. return ip
  52. except OSError:
  53. return "127.0.0.1"
  54. def _build_notify_message(self) -> bytes:
  55. """Build SSDP NOTIFY message for periodic announcements.
  56. Format matches real Bambu printer SSDP broadcasts observed on the network.
  57. Real printers use Host: 239.255.255.250:1990 (port 1990 in header).
  58. """
  59. ip = self._get_local_ip()
  60. # Match exact format of real Bambu printers (captured via tcpdump)
  61. # Key: DevBind.bambu.com: free - tells slicer printer is NOT cloud-bound
  62. message = (
  63. "NOTIFY * HTTP/1.1\r\n"
  64. f"Host: {SSDP_MULTICAST_ADDR}:1990\r\n"
  65. "Server: UPnP/1.0\r\n"
  66. f"Location: {ip}\r\n"
  67. f"NT: {BAMBU_SEARCH_TARGET}\r\n"
  68. "NTS: ssdp:alive\r\n"
  69. f"USN: {self.serial}\r\n"
  70. "Cache-Control: max-age=1800\r\n"
  71. f"DevModel.bambu.com: {self.model}\r\n"
  72. f"DevName.bambu.com: {self.name}\r\n"
  73. "DevSignal.bambu.com: -44\r\n"
  74. "DevConnect.bambu.com: lan\r\n"
  75. "DevBind.bambu.com: free\r\n"
  76. "Devseclink.bambu.com: secure\r\n"
  77. "DevInf.bambu.com: eth0\r\n"
  78. "DevVersion.bambu.com: 01.07.00.00\r\n"
  79. "DevCap.bambu.com: 1\r\n"
  80. "\r\n"
  81. )
  82. return message.encode()
  83. def _build_response_message(self) -> bytes:
  84. """Build SSDP response message for M-SEARCH requests.
  85. Format matches real Bambu printer SSDP responses.
  86. """
  87. ip = self._get_local_ip()
  88. # Match format of real Bambu printers
  89. # Key: DevBind.bambu.com: free - tells slicer printer is NOT cloud-bound
  90. message = (
  91. "HTTP/1.1 200 OK\r\n"
  92. "Server: UPnP/1.0\r\n"
  93. f"Location: {ip}\r\n"
  94. f"ST: {BAMBU_SEARCH_TARGET}\r\n"
  95. f"USN: {self.serial}\r\n"
  96. "Cache-Control: max-age=1800\r\n"
  97. f"DevModel.bambu.com: {self.model}\r\n"
  98. f"DevName.bambu.com: {self.name}\r\n"
  99. "DevSignal.bambu.com: -44\r\n"
  100. "DevConnect.bambu.com: lan\r\n"
  101. "DevBind.bambu.com: free\r\n"
  102. "Devseclink.bambu.com: secure\r\n"
  103. "DevInf.bambu.com: eth0\r\n"
  104. "DevVersion.bambu.com: 01.07.00.00\r\n"
  105. "DevCap.bambu.com: 1\r\n"
  106. "\r\n"
  107. )
  108. return message.encode()
  109. async def start(self) -> None:
  110. """Start the SSDP server."""
  111. if self._running:
  112. return
  113. logger.info("Starting virtual printer SSDP server: %s (%s)", self.name, self.serial)
  114. self._running = True
  115. try:
  116. # Create UDP socket
  117. self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
  118. self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  119. # Try to set SO_REUSEPORT if available
  120. try:
  121. self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
  122. except (AttributeError, OSError):
  123. pass
  124. # Set non-blocking mode
  125. self._socket.setblocking(False)
  126. # Bind to SSDP port
  127. self._socket.bind(("", SSDP_PORT))
  128. # Join multicast group
  129. mreq = struct.pack("4sl", socket.inet_aton(SSDP_MULTICAST_ADDR), socket.INADDR_ANY)
  130. self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
  131. # Enable broadcast
  132. self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
  133. # Set multicast TTL
  134. self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
  135. local_ip = self._get_local_ip()
  136. logger.info("SSDP server listening on port %s, advertising IP: %s", SSDP_PORT, local_ip)
  137. logger.info("Virtual printer: %s (%s) model=%s", self.name, self.serial, self.model)
  138. # Send initial NOTIFY
  139. await self._send_notify()
  140. logger.info("Sent initial SSDP NOTIFY announcement")
  141. # Run receive and announce loops
  142. last_notify = asyncio.get_event_loop().time()
  143. notify_interval = 30.0 # Send NOTIFY every 30 seconds
  144. while self._running:
  145. # Try to receive M-SEARCH requests
  146. try:
  147. data, addr = self._socket.recvfrom(4096)
  148. message = data.decode("utf-8", errors="ignore")
  149. await self._handle_message(message, addr)
  150. except BlockingIOError:
  151. pass
  152. except OSError as e:
  153. if self._running:
  154. logger.debug("SSDP receive error: %s", e)
  155. # Send periodic NOTIFY
  156. now = asyncio.get_event_loop().time()
  157. if now - last_notify >= notify_interval:
  158. await self._send_notify()
  159. last_notify = now
  160. await asyncio.sleep(0.1)
  161. except OSError as e:
  162. if e.errno == 98: # Address already in use
  163. logger.warning("SSDP port %s in use - real printers may be running", SSDP_PORT)
  164. else:
  165. logger.error("SSDP server error: %s", e)
  166. except asyncio.CancelledError:
  167. logger.debug("SSDP server cancelled")
  168. except Exception as e:
  169. logger.error("SSDP server error: %s", e)
  170. finally:
  171. await self._cleanup()
  172. async def stop(self) -> None:
  173. """Stop the SSDP server."""
  174. logger.info("Stopping SSDP server")
  175. self._running = False
  176. await self._cleanup()
  177. async def _cleanup(self) -> None:
  178. """Clean up resources."""
  179. if self._socket:
  180. try:
  181. # Send byebye message
  182. await self._send_byebye()
  183. except OSError:
  184. pass
  185. try:
  186. self._socket.close()
  187. except OSError:
  188. pass
  189. self._socket = None
  190. async def _send_notify(self) -> None:
  191. """Send SSDP NOTIFY message via broadcast (like real Bambu printers)."""
  192. if not self._socket:
  193. return
  194. try:
  195. msg = self._build_notify_message()
  196. # Real Bambu printers broadcast to 255.255.255.255, not multicast
  197. self._socket.sendto(msg, (SSDP_BROADCAST_ADDR, SSDP_PORT))
  198. logger.debug("Sent SSDP NOTIFY for %s", self.name)
  199. except OSError as e:
  200. logger.debug("Failed to send NOTIFY: %s", e)
  201. async def _send_byebye(self) -> None:
  202. """Send SSDP byebye message when shutting down."""
  203. if not self._socket:
  204. return
  205. message = (
  206. "NOTIFY * HTTP/1.1\r\n"
  207. f"Host: {SSDP_MULTICAST_ADDR}:1990\r\n"
  208. f"NT: {BAMBU_SEARCH_TARGET}\r\n"
  209. "NTS: ssdp:byebye\r\n"
  210. f"USN: {self.serial}\r\n"
  211. "\r\n"
  212. )
  213. try:
  214. self._socket.sendto(message.encode(), (SSDP_BROADCAST_ADDR, SSDP_PORT))
  215. logger.debug("Sent SSDP byebye")
  216. except OSError:
  217. pass
  218. async def _handle_message(self, message: str, addr: tuple[str, int]) -> None:
  219. """Handle incoming SSDP message.
  220. Args:
  221. message: The SSDP message content
  222. addr: Tuple of (ip_address, port) of sender
  223. """
  224. # Check if this is an M-SEARCH request for Bambu printers
  225. if "M-SEARCH" not in message:
  226. return
  227. # Check search target
  228. if BAMBU_SEARCH_TARGET not in message and "ssdp:all" not in message.lower():
  229. return
  230. logger.debug("Received M-SEARCH from %s", addr[0])
  231. # Send response
  232. if self._socket:
  233. try:
  234. response = self._build_response_message()
  235. self._socket.sendto(response, addr)
  236. logger.info("Sent SSDP response to %s for virtual printer '%s'", addr[0], self.name)
  237. except OSError as e:
  238. logger.debug("Failed to send SSDP response: %s", e)
  239. class SSDPProxy:
  240. """SSDP proxy that re-broadcasts printer discovery from one network to another.
  241. Listens for SSDP broadcasts from a real printer on the local interface (LAN A),
  242. then re-broadcasts them on the remote interface (LAN B) with the Location
  243. header changed to point to Bambuddy's IP on LAN B.
  244. This allows Bambu Studio on LAN B to discover the printer via Bambuddy.
  245. """
  246. def __init__(
  247. self,
  248. local_interface_ip: str,
  249. remote_interface_ip: str,
  250. target_printer_ip: str,
  251. ):
  252. """Initialize the SSDP proxy.
  253. Args:
  254. local_interface_ip: IP of interface on printer's network (LAN A)
  255. remote_interface_ip: IP of interface on slicer's network (LAN B)
  256. target_printer_ip: IP of the real printer to proxy SSDP for
  257. """
  258. self.local_interface_ip = local_interface_ip
  259. self.remote_interface_ip = remote_interface_ip
  260. self.target_printer_ip = target_printer_ip
  261. self._running = False
  262. self._local_socket: socket.socket | None = None
  263. self._remote_socket: socket.socket | None = None
  264. self._last_printer_ssdp: bytes | None = None
  265. self._printer_info: dict[str, str] = {}
  266. def _parse_ssdp_message(self, data: bytes) -> dict[str, str]:
  267. """Parse SSDP message into header dict."""
  268. headers = {}
  269. try:
  270. text = data.decode("utf-8", errors="ignore")
  271. for line in text.split("\r\n"):
  272. if ":" in line:
  273. key, value = line.split(":", 1)
  274. headers[key.strip().lower()] = value.strip()
  275. except Exception:
  276. pass
  277. return headers
  278. def _rewrite_ssdp_location(self, data: bytes) -> bytes:
  279. """Rewrite SSDP message with Bambuddy's remote IP as Location."""
  280. try:
  281. text = data.decode("utf-8", errors="ignore")
  282. original = text
  283. # Replace Location header with our remote interface IP
  284. text = re.sub(
  285. r"(Location:\s*)[\d.]+",
  286. f"\\g<1>{self.remote_interface_ip}",
  287. text,
  288. flags=re.IGNORECASE,
  289. )
  290. if text != original:
  291. logger.debug("Rewrote SSDP Location to %s", self.remote_interface_ip)
  292. logger.debug("Rewritten SSDP packet:\n%s", text)
  293. else:
  294. logger.warning("SSDP Location rewrite had no effect. Packet:\n%s", original)
  295. return text.encode("utf-8")
  296. except Exception as e:
  297. logger.error("Failed to rewrite SSDP: %s", e)
  298. return data
  299. async def start(self) -> None:
  300. """Start the SSDP proxy."""
  301. if self._running:
  302. return
  303. logger.info(
  304. f"Starting SSDP proxy: listening on {self.local_interface_ip} (LAN A), "
  305. f"broadcasting on {self.remote_interface_ip} (LAN B), "
  306. f"proxying printer {self.target_printer_ip}"
  307. )
  308. self._running = True
  309. try:
  310. # Create socket for listening on LAN A (printer network)
  311. # Bind to 0.0.0.0 to receive broadcast packets (255.255.255.255)
  312. # We filter by source IP in the handler
  313. self._local_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
  314. self._local_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  315. try:
  316. self._local_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
  317. except (AttributeError, OSError):
  318. pass
  319. self._local_socket.setblocking(False)
  320. # Bind to all interfaces to receive broadcasts
  321. self._local_socket.bind(("", SSDP_PORT))
  322. # Join multicast group on local interface (for multicast SSDP if used)
  323. mreq = struct.pack(
  324. "4s4s",
  325. socket.inet_aton(SSDP_MULTICAST_ADDR),
  326. socket.inet_aton(self.local_interface_ip),
  327. )
  328. self._local_socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
  329. self._local_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
  330. # Create socket for broadcasting on LAN B (slicer network)
  331. self._remote_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
  332. self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  333. try:
  334. self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
  335. except (AttributeError, OSError):
  336. pass
  337. self._remote_socket.setblocking(False)
  338. # Bind to remote interface
  339. self._remote_socket.bind((self.remote_interface_ip, 0))
  340. self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
  341. logger.info(
  342. "SSDP proxy listening on 0.0.0.0:%s (filtering for printer %s)", SSDP_PORT, self.target_printer_ip
  343. )
  344. logger.info("SSDP proxy will broadcast on %s", self.remote_interface_ip)
  345. # Main loop
  346. last_broadcast = 0.0
  347. broadcast_interval = 30.0 # Re-broadcast every 30 seconds
  348. while self._running:
  349. # Listen for SSDP from printer on LAN A
  350. try:
  351. data, addr = self._local_socket.recvfrom(4096)
  352. await self._handle_local_packet(data, addr)
  353. except BlockingIOError:
  354. pass
  355. except OSError as e:
  356. if self._running:
  357. logger.debug("SSDP proxy receive error: %s", e)
  358. # Listen for M-SEARCH from slicer on LAN B (via remote socket would need separate bind)
  359. # For now, we periodically re-broadcast cached printer SSDP
  360. now = asyncio.get_event_loop().time()
  361. if self._last_printer_ssdp and now - last_broadcast >= broadcast_interval:
  362. await self._broadcast_to_remote()
  363. last_broadcast = now
  364. await asyncio.sleep(0.1)
  365. except OSError as e:
  366. logger.error("SSDP proxy error: %s", e)
  367. except asyncio.CancelledError:
  368. logger.debug("SSDP proxy cancelled")
  369. except Exception as e:
  370. logger.error("SSDP proxy error: %s", e)
  371. finally:
  372. await self._cleanup()
  373. async def stop(self) -> None:
  374. """Stop the SSDP proxy."""
  375. logger.info("Stopping SSDP proxy")
  376. self._running = False
  377. await self._cleanup()
  378. async def _cleanup(self) -> None:
  379. """Clean up resources."""
  380. for sock in [self._local_socket, self._remote_socket]:
  381. if sock:
  382. try:
  383. sock.close()
  384. except OSError:
  385. pass
  386. self._local_socket = None
  387. self._remote_socket = None
  388. async def _handle_local_packet(self, data: bytes, addr: tuple[str, int]) -> None:
  389. """Handle SSDP packet received on local interface (LAN A)."""
  390. sender_ip = addr[0]
  391. # Only process packets from the target printer
  392. if sender_ip != self.target_printer_ip:
  393. return
  394. # Check if it's a NOTIFY message
  395. if b"NOTIFY" not in data and b"HTTP/1.1 200" not in data:
  396. return
  397. # Check if it's a Bambu printer SSDP
  398. if b"bambulab-com:device:3dprinter" not in data:
  399. return
  400. # Parse and store printer info
  401. headers = self._parse_ssdp_message(data)
  402. if headers:
  403. self._printer_info = headers
  404. logger.debug("Received SSDP from printer %s: %s", sender_ip, headers.get("devname.bambu.com", "unknown"))
  405. # Store and immediately broadcast
  406. self._last_printer_ssdp = data
  407. await self._broadcast_to_remote()
  408. async def _broadcast_to_remote(self) -> None:
  409. """Broadcast cached printer SSDP on remote interface (LAN B)."""
  410. if not self._remote_socket or not self._last_printer_ssdp:
  411. return
  412. try:
  413. # Rewrite Location to point to Bambuddy's remote interface
  414. rewritten = self._rewrite_ssdp_location(self._last_printer_ssdp)
  415. # Calculate broadcast address for remote network
  416. # Use 255.255.255.255 for simplicity (works across subnets)
  417. self._remote_socket.sendto(rewritten, (SSDP_BROADCAST_ADDR, SSDP_PORT))
  418. printer_name = self._printer_info.get("devname.bambu.com", "unknown")
  419. logger.debug("Broadcast SSDP for '%s' on LAN B (%s)", printer_name, self.remote_interface_ip)
  420. except OSError as e:
  421. logger.debug("Failed to broadcast SSDP on remote: %s", e)