ssdp_server.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653
  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. advertise_ip: str = "",
  28. bind_ip: str = "",
  29. extra_interfaces: list[str] | None = None,
  30. ):
  31. """Initialize the SSDP server.
  32. Args:
  33. name: Display name shown in slicer discovery
  34. serial: Unique serial number
  35. model: Model code
  36. advertise_ip: Override IP to advertise instead of auto-detecting
  37. bind_ip: IP address to bind the SSDP socket to
  38. extra_interfaces: Additional interface IPs to broadcast on (e.g. VPN).
  39. NOTIFY and M-SEARCH responses are sent on these interfaces too,
  40. but Location always points to the bind IP so the slicer connects
  41. to the correct address for MQTT/FTP.
  42. """
  43. self.name = name
  44. self.serial = serial
  45. self.model = model
  46. self._bind_ip = bind_ip
  47. self._running = False
  48. # Set after the primary multicast socket is bound — see ftp_server.py
  49. # for rationale.
  50. self.ready = asyncio.Event()
  51. self._socket: socket.socket | None = None
  52. self._extra_sockets: list[socket.socket] = []
  53. self._extra_interfaces = extra_interfaces or []
  54. self._local_ip: str | None = advertise_ip or bind_ip or None
  55. def _get_local_ip(self) -> str:
  56. """Get the local IP address to advertise."""
  57. if self._local_ip:
  58. return self._local_ip
  59. # Try to determine local IP by connecting to a public address
  60. try:
  61. s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  62. s.connect(("8.8.8.8", 80))
  63. ip = s.getsockname()[0]
  64. s.close()
  65. self._local_ip = ip
  66. return ip
  67. except OSError:
  68. return "127.0.0.1"
  69. def _build_notify_message(self) -> bytes:
  70. """Build SSDP NOTIFY message for periodic announcements."""
  71. ip = self._get_local_ip()
  72. message = (
  73. "NOTIFY * HTTP/1.1\r\n"
  74. f"Host: {SSDP_MULTICAST_ADDR}:1990\r\n"
  75. "Server: UPnP/1.0\r\n"
  76. f"Location: {ip}\r\n"
  77. f"NT: {BAMBU_SEARCH_TARGET}\r\n"
  78. "NTS: ssdp:alive\r\n"
  79. f"USN: {self.serial}\r\n"
  80. "Cache-Control: max-age=1800\r\n"
  81. f"DevModel.bambu.com: {self.model}\r\n"
  82. f"DevName.bambu.com: {self.name}\r\n"
  83. "DevSignal.bambu.com: -44\r\n"
  84. "DevConnect.bambu.com: lan\r\n"
  85. "DevBind.bambu.com: free\r\n"
  86. "Devseclink.bambu.com: secure\r\n"
  87. "DevInf.bambu.com: eth0\r\n"
  88. "DevVersion.bambu.com: 01.07.00.00\r\n"
  89. "DevCap.bambu.com: 1\r\n"
  90. "\r\n"
  91. )
  92. return message.encode()
  93. def _build_response_message(self) -> bytes:
  94. """Build SSDP response message for M-SEARCH requests."""
  95. ip = self._get_local_ip()
  96. message = (
  97. "HTTP/1.1 200 OK\r\n"
  98. "Server: UPnP/1.0\r\n"
  99. f"Location: {ip}\r\n"
  100. f"ST: {BAMBU_SEARCH_TARGET}\r\n"
  101. f"USN: {self.serial}\r\n"
  102. "Cache-Control: max-age=1800\r\n"
  103. f"DevModel.bambu.com: {self.model}\r\n"
  104. f"DevName.bambu.com: {self.name}\r\n"
  105. "DevSignal.bambu.com: -44\r\n"
  106. "DevConnect.bambu.com: lan\r\n"
  107. "DevBind.bambu.com: free\r\n"
  108. "Devseclink.bambu.com: secure\r\n"
  109. "DevInf.bambu.com: eth0\r\n"
  110. "DevVersion.bambu.com: 01.07.00.00\r\n"
  111. "DevCap.bambu.com: 1\r\n"
  112. "\r\n"
  113. )
  114. return message.encode()
  115. async def start(self) -> None:
  116. """Start the SSDP server."""
  117. if self._running:
  118. return
  119. logger.info("Starting virtual printer SSDP server: %s (%s)", self.name, self.serial)
  120. self._running = True
  121. try:
  122. # Create UDP socket
  123. self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
  124. self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  125. # Try to set SO_REUSEPORT if available
  126. try:
  127. self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
  128. except (AttributeError, OSError):
  129. pass # SO_REUSEPORT not available on all platforms; non-critical
  130. # Set non-blocking mode
  131. self._socket.setblocking(False)
  132. # Bind to SSDP port on specific interface (or all interfaces)
  133. self._socket.bind((self._bind_ip or "", SSDP_PORT))
  134. # Join multicast group (on specific interface if bind_ip is set)
  135. if self._bind_ip:
  136. mreq = struct.pack(
  137. "4s4s",
  138. socket.inet_aton(SSDP_MULTICAST_ADDR),
  139. socket.inet_aton(self._bind_ip),
  140. )
  141. else:
  142. mreq = struct.pack("4sl", socket.inet_aton(SSDP_MULTICAST_ADDR), socket.INADDR_ANY)
  143. self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
  144. # Enable broadcast
  145. self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
  146. # Set multicast TTL
  147. self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
  148. local_ip = self._get_local_ip()
  149. logger.info("SSDP server listening on port %s, advertising IP: %s", SSDP_PORT, local_ip)
  150. logger.info("Virtual printer: %s (%s) model=%s", self.name, self.serial, self.model)
  151. self.ready.set()
  152. # Create extra sockets for additional interfaces (VPN, etc.)
  153. # If no explicit extra interfaces given and we're bound to a
  154. # specific IP, add a wildcard socket to catch M-SEARCH from
  155. # other subnets (VPN tunnels, secondary NICs, etc.)
  156. extra_ips = list(self._extra_interfaces)
  157. if not extra_ips and self._bind_ip:
  158. extra_ips.append("0.0.0.0") # nosec B104
  159. for iface_ip in extra_ips:
  160. try:
  161. sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
  162. sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  163. try:
  164. sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
  165. except (AttributeError, OSError):
  166. pass
  167. sock.setblocking(False)
  168. sock.bind((iface_ip, SSDP_PORT))
  169. sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
  170. self._extra_sockets.append(sock)
  171. logger.info("SSDP server also listening on %s:%s", iface_ip, SSDP_PORT)
  172. except OSError as e:
  173. logger.warning("SSDP server: failed to bind extra interface %s: %s", iface_ip, e)
  174. # Send initial NOTIFY
  175. await self._send_notify()
  176. logger.info("Sent initial SSDP NOTIFY announcement")
  177. # Run receive and announce loops
  178. last_notify = asyncio.get_event_loop().time()
  179. notify_interval = 30.0 # Send NOTIFY every 30 seconds
  180. while self._running:
  181. # Try to receive M-SEARCH requests on primary socket
  182. try:
  183. data, addr = self._socket.recvfrom(4096)
  184. message = data.decode("utf-8", errors="ignore")
  185. await self._handle_message(message, addr)
  186. except BlockingIOError:
  187. pass # No data available on non-blocking socket; will retry
  188. except OSError as e:
  189. if self._running:
  190. logger.debug("SSDP receive error: %s", e)
  191. # Try to receive M-SEARCH requests on extra sockets
  192. for sock in self._extra_sockets:
  193. try:
  194. data, addr = sock.recvfrom(4096)
  195. message = data.decode("utf-8", errors="ignore")
  196. await self._handle_message(message, addr, sock)
  197. except BlockingIOError:
  198. pass
  199. except OSError:
  200. pass
  201. # Send periodic NOTIFY
  202. now = asyncio.get_event_loop().time()
  203. if now - last_notify >= notify_interval:
  204. await self._send_notify()
  205. last_notify = now
  206. await asyncio.sleep(0.1)
  207. except OSError as e:
  208. if e.errno == 98: # Address already in use
  209. logger.warning("SSDP port %s in use - real printers may be running", SSDP_PORT)
  210. else:
  211. logger.error("SSDP server error: %s", e)
  212. except asyncio.CancelledError:
  213. logger.debug("SSDP server cancelled")
  214. except Exception as e:
  215. logger.error("SSDP server error: %s", e)
  216. finally:
  217. await self._cleanup()
  218. async def stop(self) -> None:
  219. """Stop the SSDP server."""
  220. logger.info("Stopping SSDP server")
  221. self._running = False
  222. self.ready.clear()
  223. await self._cleanup()
  224. async def _cleanup(self) -> None:
  225. """Clean up resources."""
  226. if self._socket:
  227. try:
  228. # Send byebye message
  229. await self._send_byebye()
  230. except OSError:
  231. pass # Best-effort byebye broadcast; socket may already be closed
  232. try:
  233. self._socket.close()
  234. except OSError:
  235. pass # Best-effort socket close; may already be released
  236. self._socket = None
  237. for sock in self._extra_sockets:
  238. try:
  239. sock.close()
  240. except OSError:
  241. pass
  242. self._extra_sockets = []
  243. async def _send_notify(self) -> None:
  244. """Send SSDP NOTIFY message via broadcast on all sockets."""
  245. msg = self._build_notify_message()
  246. if self._socket:
  247. try:
  248. self._socket.sendto(msg, (SSDP_BROADCAST_ADDR, SSDP_PORT))
  249. logger.debug(
  250. "Sent SSDP NOTIFY for %s (Location=%s, USN=%s, bind=%s)",
  251. self.name,
  252. self._get_local_ip(),
  253. self.serial,
  254. self._bind_ip,
  255. )
  256. except OSError as e:
  257. logger.debug("Failed to send NOTIFY for %s: %s", self.name, e)
  258. for sock in self._extra_sockets:
  259. try:
  260. sock.sendto(msg, (SSDP_BROADCAST_ADDR, SSDP_PORT))
  261. except OSError:
  262. pass # Best-effort broadcast on extra interfaces
  263. async def _send_byebye(self) -> None:
  264. """Send SSDP byebye message when shutting down."""
  265. if not self._socket:
  266. return
  267. message = (
  268. "NOTIFY * HTTP/1.1\r\n"
  269. f"Host: {SSDP_MULTICAST_ADDR}:1990\r\n"
  270. f"NT: {BAMBU_SEARCH_TARGET}\r\n"
  271. "NTS: ssdp:byebye\r\n"
  272. f"USN: {self.serial}\r\n"
  273. "\r\n"
  274. )
  275. try:
  276. self._socket.sendto(message.encode(), (SSDP_BROADCAST_ADDR, SSDP_PORT))
  277. logger.debug("Sent SSDP byebye")
  278. except OSError:
  279. pass # Best-effort byebye send; network may be unavailable during shutdown
  280. async def _handle_message(
  281. self, message: str, addr: tuple[str, int], reply_socket: socket.socket | None = None
  282. ) -> None:
  283. """Handle incoming SSDP message.
  284. Args:
  285. message: The SSDP message content
  286. addr: Tuple of (ip_address, port) of sender
  287. reply_socket: Socket to send the response on (defaults to primary)
  288. """
  289. # Check if this is an M-SEARCH request for Bambu printers
  290. if "M-SEARCH" not in message:
  291. return
  292. # Check search target
  293. if BAMBU_SEARCH_TARGET not in message and "ssdp:all" not in message.lower():
  294. return
  295. logger.debug("Received M-SEARCH from %s", addr[0])
  296. # Send response on the socket that received the request
  297. sock = reply_socket or self._socket
  298. if sock:
  299. try:
  300. response = self._build_response_message()
  301. sock.sendto(response, addr)
  302. logger.info(
  303. "Sent SSDP response to %s for '%s' (Location=%s, USN=%s)",
  304. addr[0],
  305. self.name,
  306. self._get_local_ip(),
  307. self.serial,
  308. )
  309. except OSError as e:
  310. logger.debug("Failed to send SSDP response for %s: %s", self.name, e)
  311. class SSDPProxy:
  312. """SSDP proxy that re-broadcasts printer discovery from one network to another.
  313. Listens for SSDP broadcasts from a real printer on the local interface (LAN A),
  314. then re-broadcasts them on the remote interface (LAN B) with the Location
  315. header changed to point to Bambuddy's IP on LAN B.
  316. This allows Bambu Studio on LAN B to discover the printer via Bambuddy.
  317. """
  318. def __init__(
  319. self,
  320. local_interface_ip: str,
  321. remote_interface_ip: str,
  322. target_printer_ip: str,
  323. name: str | None = None,
  324. ):
  325. """Initialize the SSDP proxy.
  326. Args:
  327. local_interface_ip: IP of interface on printer's network (LAN A)
  328. remote_interface_ip: IP of interface on slicer's network (LAN B)
  329. target_printer_ip: IP of the real printer to proxy SSDP for
  330. name: Optional VP name to advertise (replaces printer's real name)
  331. """
  332. self.local_interface_ip = local_interface_ip
  333. self.remote_interface_ip = remote_interface_ip
  334. self.target_printer_ip = target_printer_ip
  335. self.proxy_name = name
  336. self._running = False
  337. self._local_socket: socket.socket | None = None
  338. self._remote_socket: socket.socket | None = None
  339. self._last_printer_ssdp: bytes | None = None
  340. self._printer_info: dict[str, str] = {}
  341. def _parse_ssdp_message(self, data: bytes) -> dict[str, str]:
  342. """Parse SSDP message into header dict."""
  343. headers = {}
  344. try:
  345. text = data.decode("utf-8", errors="ignore")
  346. for line in text.split("\r\n"):
  347. if ":" in line:
  348. key, value = line.split(":", 1)
  349. headers[key.strip().lower()] = value.strip()
  350. except Exception:
  351. pass # Return partial headers if parsing fails; malformed packets are common
  352. return headers
  353. def _rewrite_ssdp(self, data: bytes) -> bytes:
  354. """Rewrite SSDP message for proxy re-broadcast.
  355. - Location: changed to Bambuddy's remote interface IP
  356. - DevBind: forced to 'free' so the slicer treats the proxy as a
  357. LAN-only printer (avoids cloud auth requirement for sending prints)
  358. """
  359. try:
  360. text = data.decode("utf-8", errors="ignore")
  361. original = text
  362. # Replace Location header with our remote interface IP
  363. text = re.sub(
  364. r"(Location:\s*)[\d.]+",
  365. f"\\g<1>{self.remote_interface_ip}",
  366. text,
  367. flags=re.IGNORECASE,
  368. )
  369. # Force DevBind to 'free' - ensures slicer uses LAN mode for
  370. # both monitoring AND sending prints through the proxy
  371. text = re.sub(
  372. r"(DevBind\.bambu\.com:\s*)\S+",
  373. r"\g<1>free",
  374. text,
  375. flags=re.IGNORECASE,
  376. )
  377. # Replace printer name with configured VP name, or append " - Proxy"
  378. if self.proxy_name:
  379. text = re.sub(
  380. r"(DevName\.bambu\.com:\s*)[^\r\n]+",
  381. rf"\g<1>{self.proxy_name}",
  382. text,
  383. flags=re.IGNORECASE,
  384. )
  385. else:
  386. text = re.sub(
  387. r"(DevName\.bambu\.com:\s*)([^\r\n]+)",
  388. r"\g<1>\g<2> - Proxy",
  389. text,
  390. flags=re.IGNORECASE,
  391. )
  392. if text != original:
  393. logger.debug("Rewrote SSDP for proxy:\n%s", text)
  394. else:
  395. logger.warning("SSDP rewrite had no effect. Packet:\n%s", original)
  396. return text.encode("utf-8")
  397. except Exception as e:
  398. logger.error("Failed to rewrite SSDP: %s", e)
  399. return data
  400. async def start(self) -> None:
  401. """Start the SSDP proxy."""
  402. if self._running:
  403. return
  404. logger.info(
  405. f"Starting SSDP proxy: listening on {self.local_interface_ip} (LAN A), "
  406. f"broadcasting on {self.remote_interface_ip} (LAN B), "
  407. f"proxying printer {self.target_printer_ip}"
  408. )
  409. self._running = True
  410. try:
  411. # Create socket for listening on LAN A (printer network)
  412. # Bind to 0.0.0.0 to receive broadcast packets (255.255.255.255)
  413. # We filter by source IP in the handler
  414. self._local_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
  415. self._local_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  416. try:
  417. self._local_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
  418. except (AttributeError, OSError):
  419. pass # SO_REUSEPORT not available on all platforms; non-critical
  420. self._local_socket.setblocking(False)
  421. # Bind to all interfaces to receive broadcasts
  422. self._local_socket.bind(("", SSDP_PORT))
  423. # Join multicast group on local interface (for multicast SSDP if used)
  424. mreq = struct.pack(
  425. "4s4s",
  426. socket.inet_aton(SSDP_MULTICAST_ADDR),
  427. socket.inet_aton(self.local_interface_ip),
  428. )
  429. self._local_socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
  430. self._local_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
  431. # Create socket for broadcasting on LAN B (slicer network)
  432. self._remote_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
  433. self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  434. try:
  435. self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
  436. except (AttributeError, OSError):
  437. pass # SO_REUSEPORT not available on all platforms; non-critical
  438. self._remote_socket.setblocking(False)
  439. # Bind to remote interface
  440. self._remote_socket.bind((self.remote_interface_ip, 0))
  441. self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
  442. logger.info(
  443. "SSDP proxy listening on 0.0.0.0:%s (filtering for printer %s)", SSDP_PORT, self.target_printer_ip
  444. )
  445. logger.info("SSDP proxy will broadcast on %s", self.remote_interface_ip)
  446. # Main loop
  447. last_broadcast = 0.0
  448. broadcast_interval = 30.0 # Re-broadcast every 30 seconds
  449. while self._running:
  450. # Listen for SSDP from printer on LAN A
  451. try:
  452. data, addr = self._local_socket.recvfrom(4096)
  453. await self._handle_local_packet(data, addr)
  454. except BlockingIOError:
  455. pass # No data available on non-blocking socket; will retry
  456. except OSError as e:
  457. if self._running:
  458. logger.debug("SSDP proxy receive error: %s", e)
  459. # Listen for M-SEARCH from slicer on LAN B (via remote socket would need separate bind)
  460. # For now, we periodically re-broadcast cached printer SSDP
  461. now = asyncio.get_event_loop().time()
  462. if self._last_printer_ssdp and now - last_broadcast >= broadcast_interval:
  463. await self._broadcast_to_remote()
  464. last_broadcast = now
  465. await asyncio.sleep(0.1)
  466. except OSError as e:
  467. logger.error("SSDP proxy error: %s", e)
  468. except asyncio.CancelledError:
  469. logger.debug("SSDP proxy cancelled")
  470. except Exception as e:
  471. logger.error("SSDP proxy error: %s", e)
  472. finally:
  473. await self._cleanup()
  474. async def stop(self) -> None:
  475. """Stop the SSDP proxy."""
  476. logger.info("Stopping SSDP proxy")
  477. self._running = False
  478. await self._cleanup()
  479. async def _cleanup(self) -> None:
  480. """Clean up resources."""
  481. for sock in [self._local_socket, self._remote_socket]:
  482. if sock:
  483. try:
  484. sock.close()
  485. except OSError:
  486. pass # Best-effort socket close; may already be released
  487. self._local_socket = None
  488. self._remote_socket = None
  489. async def _handle_local_packet(self, data: bytes, addr: tuple[str, int]) -> None:
  490. """Handle SSDP packet received on local interface (LAN A).
  491. Processes two types of traffic:
  492. - NOTIFY from the real printer → cache and re-broadcast on LAN B
  493. - M-SEARCH from slicers on LAN B → respond with cached printer info
  494. """
  495. sender_ip = addr[0]
  496. # Ignore packets from our own interfaces (prevent loops)
  497. if sender_ip in (self.local_interface_ip, self.remote_interface_ip):
  498. return
  499. # Handle M-SEARCH from slicers (any IP that's not the target printer)
  500. if sender_ip != self.target_printer_ip:
  501. if b"M-SEARCH" in data:
  502. await self._respond_to_msearch(data, addr)
  503. return
  504. # Below: NOTIFY handling from the real printer
  505. # Check if it's a NOTIFY message
  506. if b"NOTIFY" not in data and b"HTTP/1.1 200" not in data:
  507. return
  508. # Check if it's a Bambu printer SSDP
  509. if b"bambulab-com:device:3dprinter" not in data:
  510. return
  511. # Parse and store printer info
  512. headers = self._parse_ssdp_message(data)
  513. if headers:
  514. self._printer_info = headers
  515. logger.debug("Received SSDP from printer %s: %s", sender_ip, headers.get("devname.bambu.com", "unknown"))
  516. # Store and immediately broadcast
  517. self._last_printer_ssdp = data
  518. await self._broadcast_to_remote()
  519. async def _respond_to_msearch(self, data: bytes, addr: tuple[str, int]) -> None:
  520. """Respond to M-SEARCH from a slicer with cached, rewritten printer info.
  521. When Bambu Studio sends an M-SEARCH (e.g., before sending a print),
  522. we respond with the cached printer info, rewritten to point to the
  523. proxy's LAN B IP. Without this, the slicer thinks the printer is
  524. offline and shows a 'connect to printer' modal.
  525. """
  526. # Check if it's a relevant M-SEARCH
  527. if b"bambulab-com:device:3dprinter" not in data and b"ssdp:all" not in data.lower():
  528. return
  529. if not self._last_printer_ssdp:
  530. logger.debug("M-SEARCH from %s but no cached printer SSDP yet", addr[0])
  531. return
  532. logger.debug("Received M-SEARCH from slicer %s", addr[0])
  533. # Rewrite the cached printer SSDP (Location → proxy IP, DevBind → free)
  534. rewritten = self._rewrite_ssdp(self._last_printer_ssdp)
  535. text = rewritten.decode("utf-8", errors="ignore")
  536. # Convert NOTIFY format to M-SEARCH response format:
  537. # "NOTIFY * HTTP/1.1" → "HTTP/1.1 200 OK"
  538. # NT: → ST: (Notification Type → Search Target)
  539. # Remove NTS: header (only in NOTIFY)
  540. text = re.sub(r"^NOTIFY \* HTTP/1\.1", "HTTP/1.1 200 OK", text)
  541. text = re.sub(r"^NT:", "ST:", text, flags=re.MULTILINE)
  542. text = re.sub(r"^NTS:.*\r\n", "", text, flags=re.MULTILINE)
  543. # Send unicast response directly to the slicer via remote socket
  544. if self._remote_socket:
  545. try:
  546. self._remote_socket.sendto(text.encode("utf-8"), addr)
  547. logger.info("Sent SSDP M-SEARCH response to %s", addr[0])
  548. except OSError as e:
  549. logger.debug("Failed to send M-SEARCH response to %s: %s", addr[0], e)
  550. async def _broadcast_to_remote(self) -> None:
  551. """Broadcast cached printer SSDP on remote interface (LAN B)."""
  552. if not self._remote_socket or not self._last_printer_ssdp:
  553. return
  554. try:
  555. # Rewrite Location to point to Bambuddy's remote interface
  556. rewritten = self._rewrite_ssdp(self._last_printer_ssdp)
  557. # Calculate broadcast address for remote network
  558. # Use 255.255.255.255 for simplicity (works across subnets)
  559. self._remote_socket.sendto(rewritten, (SSDP_BROADCAST_ADDR, SSDP_PORT))
  560. printer_name = self._printer_info.get("devname.bambu.com", "unknown")
  561. logger.debug("Broadcast SSDP for '%s' on LAN B (%s)", printer_name, self.remote_interface_ip)
  562. except OSError as e:
  563. logger.debug("Failed to broadcast SSDP on remote: %s", e)