bind_server.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. """Bind/detect server for virtual printer discovery (ports 3000 + 3002).
  2. Bambu slicers (BambuStudio, OrcaSlicer) connect to a printer on port 3000
  3. or 3002 to perform the "bind with access code" handshake before using
  4. MQTT/FTP. The port varies by slicer version, so we listen on both.
  5. Protocol:
  6. - Framing: 0xA5A5 + uint16_le(total_msg_size) + JSON payload + 0xA7A7
  7. - Slicer sends: {"login":{"command":"detect","sequence_id":"20000"}}
  8. - Printer replies: {"login":{"bind":"free","command":"detect","connect":"lan",
  9. "dev_cap":1,"id":"<serial>","model":"<model>","name":"<name>",
  10. "sequence_id":<int>,"version":"<firmware>"}}
  11. - Connection closes after one exchange.
  12. """
  13. import asyncio
  14. import json
  15. import logging
  16. import struct
  17. logger = logging.getLogger(__name__)
  18. BIND_PORTS = [3000, 3002]
  19. FRAME_HEADER = b"\xa5\xa5"
  20. FRAME_TRAILER = b"\xa7\xa7"
  21. HEADER_SIZE = 4 # 2 bytes magic + 2 bytes length
  22. TRAILER_SIZE = 2
  23. class BindServer:
  24. """Responds to slicer bind/detect requests on ports 3000 and 3002.
  25. In server mode, Bambuddy IS the printer — it responds with its own
  26. identity so the slicer can discover and bind to it.
  27. Different BambuStudio versions connect on different ports (3000 or 3002),
  28. so we listen on both to ensure compatibility.
  29. """
  30. def __init__(
  31. self,
  32. serial: str,
  33. model: str,
  34. name: str,
  35. version: str = "01.00.00.00",
  36. bind_address: str = "0.0.0.0", # nosec B104
  37. ):
  38. self.serial = serial
  39. self.model = model
  40. self.name = name
  41. self.version = version
  42. self.bind_address = bind_address
  43. self._servers: list[asyncio.Server] = []
  44. self._running = False
  45. async def start(self) -> None:
  46. """Start the bind server on ports 3000 and 3002."""
  47. if self._running:
  48. return
  49. self._running = True
  50. logger.info(
  51. "Starting bind server on ports %s (serial=%s, model=%s)",
  52. BIND_PORTS,
  53. self.serial,
  54. self.model,
  55. )
  56. try:
  57. for port in BIND_PORTS:
  58. try:
  59. server = await asyncio.start_server(
  60. self._handle_client,
  61. self.bind_address,
  62. port,
  63. )
  64. self._servers.append(server)
  65. logger.info("Bind server listening on %s:%s", self.bind_address, port)
  66. except OSError as e:
  67. if e.errno == 98:
  68. logger.warning("Bind server port %s already in use, skipping", port)
  69. elif e.errno == 13:
  70. logger.warning("Bind server: cannot bind to port %s (permission denied), skipping", port)
  71. else:
  72. logger.warning("Bind server: failed to bind port %s: %s", port, e)
  73. if not self._servers:
  74. logger.error("Bind server: could not bind to any port")
  75. return
  76. # Serve all successfully bound ports
  77. await asyncio.gather(*(s.serve_forever() for s in self._servers))
  78. except asyncio.CancelledError:
  79. logger.debug("Bind server task cancelled")
  80. except Exception as e:
  81. logger.error("Bind server error: %s", e)
  82. finally:
  83. await self.stop()
  84. async def stop(self) -> None:
  85. """Stop the bind server."""
  86. logger.info("Stopping bind server")
  87. self._running = False
  88. for server in self._servers:
  89. try:
  90. server.close()
  91. await server.wait_closed()
  92. except OSError as e:
  93. logger.debug("Error closing bind server: %s", e)
  94. self._servers = []
  95. async def _handle_client(
  96. self,
  97. reader: asyncio.StreamReader,
  98. writer: asyncio.StreamWriter,
  99. ) -> None:
  100. """Handle a single bind/detect request from a slicer."""
  101. peername = writer.get_extra_info("peername")
  102. client_id = f"{peername[0]}:{peername[1]}" if peername else "unknown"
  103. logger.info("Bind server: client connected from %s", client_id)
  104. try:
  105. # Read the framed message (timeout after 10s)
  106. data = await asyncio.wait_for(reader.read(4096), timeout=10.0)
  107. if not data:
  108. return
  109. # Parse the request
  110. request = self._parse_frame(data)
  111. if request is None:
  112. logger.warning("Bind server: invalid frame from %s", client_id)
  113. return
  114. logger.info("Bind server: received from %s: %s", client_id, request)
  115. # Check if this is a detect command
  116. login = request.get("login", {})
  117. if not isinstance(login, dict) or login.get("command") != "detect":
  118. logger.warning("Bind server: unexpected command from %s: %s", client_id, request)
  119. return
  120. # Build response
  121. response = {
  122. "login": {
  123. "bind": "free",
  124. "command": "detect",
  125. "connect": "lan",
  126. "dev_cap": 1,
  127. "id": self.serial,
  128. "model": self.model,
  129. "name": self.name,
  130. "sequence_id": 3021,
  131. "version": self.version,
  132. }
  133. }
  134. frame = self._build_frame(response)
  135. writer.write(frame)
  136. await writer.drain()
  137. logger.info("Bind server: sent detect response to %s (serial=%s)", client_id, self.serial)
  138. except TimeoutError:
  139. logger.debug("Bind server: timeout waiting for data from %s", client_id)
  140. except Exception as e:
  141. logger.error("Bind server: error handling %s: %s", client_id, e)
  142. finally:
  143. try:
  144. writer.close()
  145. await writer.wait_closed()
  146. except OSError:
  147. pass
  148. logger.debug("Bind server: client %s disconnected", client_id)
  149. def _parse_frame(self, data: bytes) -> dict | None:
  150. """Parse a framed message: 0xA5A5 + len(u16le) + JSON + 0xA7A7."""
  151. if len(data) < HEADER_SIZE + TRAILER_SIZE:
  152. return None
  153. if data[:2] != FRAME_HEADER:
  154. return None
  155. if data[-2:] != FRAME_TRAILER:
  156. return None
  157. # Length field is total message size (header + json + trailer)
  158. total_len = struct.unpack_from("<H", data, 2)[0]
  159. if total_len != len(data):
  160. logger.debug("Bind frame length mismatch: header says %d, got %d", total_len, len(data))
  161. # JSON payload is between header and trailer
  162. json_bytes = data[HEADER_SIZE:-TRAILER_SIZE]
  163. try:
  164. return json.loads(json_bytes)
  165. except (json.JSONDecodeError, UnicodeDecodeError) as e:
  166. logger.warning("Bind server: failed to parse JSON: %s", e)
  167. return None
  168. def _build_frame(self, payload: dict) -> bytes:
  169. """Build a framed message: 0xA5A5 + len(u16le) + JSON + 0xA7A7."""
  170. json_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8")
  171. total_len = HEADER_SIZE + len(json_bytes) + TRAILER_SIZE
  172. header = FRAME_HEADER + struct.pack("<H", total_len)
  173. return header + json_bytes + FRAME_TRAILER