bind_server.py 6.6 KB

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