mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
#1558: cached-as-base push_status only forced gcode_state=IDLE while letting the real printer's live-progress fields (mc_percent, stg_cur, layer_num, ...) leak through. Bambu Studio's Send pre-flight read them as busy and refused. The cached branch now overrides the activity-field set the same way it already overrode storage indicators (#1228) and protocol fields. Same bundle ships a multi-round VP audit that found adjacent bugs in the same family: - #1558: cached branch zeroes mc_print_stage / mc_percent / mc_remaining_time / stg / stg_cur / layer_num / total_layer_num / print_error - MQTT auth: per-IP rate-limit (5/60s lockout), hmac.compare_digest, access_code redacted in DEBUG log - FTP cmd_STOR streams chunks to disk + 4 GiB cap (was buffering whole upload) - Sticky-keys allowlist extended with upgrade_state / xcam / hw_switch_state / nozzle_diameter / nozzle_type / online / ams_status - _pending_files cleanup in finally for archive / queue / dispatch handlers - _add_to_print_queue position uses MAX+1 (was hardcoded 1) - DELETE VP removes orphan PendingUpload rows + upload_dir from disk - Per-VP cert regenerates on shared-CA rotation (real signature verification, not DN match) - DHCP target-IP refresh + queue_force_color_match toggle now restart proxy VPs - Per-slicer bridge-response routing (multi-slicer cross-leak fix via sequence_id map) - Child-service readiness barrier (FTP / MQTT / Bind / SSDP) — no false is_running before sockets bind - H2D Pro O1E / O2D model codes added (experimental, needs field confirmation) - FTP passive port range widened 50000-51000; docker-compose + wiki updated - VP refresh_loop crash now unbinds raw_message_handler; tailscale catches asyncio.TimeoutError; SlicerProxyManager lifecycle hardening
653 lines
26 KiB
Python
653 lines
26 KiB
Python
"""SSDP discovery responder for virtual printer.
|
|
|
|
Responds to M-SEARCH requests from slicers and sends periodic NOTIFY
|
|
announcements so the virtual printer appears as a discoverable Bambu printer.
|
|
|
|
Also provides SSDP proxy functionality for proxy mode, where Bambuddy sits
|
|
between two networks and re-broadcasts printer SSDP from LAN A to LAN B.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import re
|
|
import socket
|
|
import struct
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# SSDP addresses - Bambu uses port 2021
|
|
# Real Bambu printers broadcast to 255.255.255.255, not multicast to 239.255.255.250
|
|
SSDP_MULTICAST_ADDR = "239.255.255.250"
|
|
SSDP_BROADCAST_ADDR = "255.255.255.255"
|
|
SSDP_PORT = 2021
|
|
|
|
# Bambu service target
|
|
BAMBU_SEARCH_TARGET = "urn:bambulab-com:device:3dprinter:1"
|
|
|
|
|
|
class VirtualPrinterSSDPServer:
|
|
"""SSDP server that responds to discovery requests as a virtual Bambu printer."""
|
|
|
|
def __init__(
|
|
self,
|
|
name: str = "Bambuddy",
|
|
serial: str = "00M09A391800001", # X1C serial format for compatibility
|
|
model: str = "BL-P001", # X1C model code for best compatibility
|
|
advertise_ip: str = "",
|
|
bind_ip: str = "",
|
|
extra_interfaces: list[str] | None = None,
|
|
):
|
|
"""Initialize the SSDP server.
|
|
|
|
Args:
|
|
name: Display name shown in slicer discovery
|
|
serial: Unique serial number
|
|
model: Model code
|
|
advertise_ip: Override IP to advertise instead of auto-detecting
|
|
bind_ip: IP address to bind the SSDP socket to
|
|
extra_interfaces: Additional interface IPs to broadcast on (e.g. VPN).
|
|
NOTIFY and M-SEARCH responses are sent on these interfaces too,
|
|
but Location always points to the bind IP so the slicer connects
|
|
to the correct address for MQTT/FTP.
|
|
"""
|
|
self.name = name
|
|
self.serial = serial
|
|
self.model = model
|
|
self._bind_ip = bind_ip
|
|
self._running = False
|
|
# Set after the primary multicast socket is bound — see ftp_server.py
|
|
# for rationale.
|
|
self.ready = asyncio.Event()
|
|
self._socket: socket.socket | None = None
|
|
self._extra_sockets: list[socket.socket] = []
|
|
self._extra_interfaces = extra_interfaces or []
|
|
self._local_ip: str | None = advertise_ip or bind_ip or None
|
|
|
|
def _get_local_ip(self) -> str:
|
|
"""Get the local IP address to advertise."""
|
|
if self._local_ip:
|
|
return self._local_ip
|
|
|
|
# Try to determine local IP by connecting to a public address
|
|
try:
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
s.connect(("8.8.8.8", 80))
|
|
ip = s.getsockname()[0]
|
|
s.close()
|
|
self._local_ip = ip
|
|
return ip
|
|
except OSError:
|
|
return "127.0.0.1"
|
|
|
|
def _build_notify_message(self) -> bytes:
|
|
"""Build SSDP NOTIFY message for periodic announcements."""
|
|
ip = self._get_local_ip()
|
|
message = (
|
|
"NOTIFY * HTTP/1.1\r\n"
|
|
f"Host: {SSDP_MULTICAST_ADDR}:1990\r\n"
|
|
"Server: UPnP/1.0\r\n"
|
|
f"Location: {ip}\r\n"
|
|
f"NT: {BAMBU_SEARCH_TARGET}\r\n"
|
|
"NTS: ssdp:alive\r\n"
|
|
f"USN: {self.serial}\r\n"
|
|
"Cache-Control: max-age=1800\r\n"
|
|
f"DevModel.bambu.com: {self.model}\r\n"
|
|
f"DevName.bambu.com: {self.name}\r\n"
|
|
"DevSignal.bambu.com: -44\r\n"
|
|
"DevConnect.bambu.com: lan\r\n"
|
|
"DevBind.bambu.com: free\r\n"
|
|
"Devseclink.bambu.com: secure\r\n"
|
|
"DevInf.bambu.com: eth0\r\n"
|
|
"DevVersion.bambu.com: 01.07.00.00\r\n"
|
|
"DevCap.bambu.com: 1\r\n"
|
|
"\r\n"
|
|
)
|
|
return message.encode()
|
|
|
|
def _build_response_message(self) -> bytes:
|
|
"""Build SSDP response message for M-SEARCH requests."""
|
|
ip = self._get_local_ip()
|
|
message = (
|
|
"HTTP/1.1 200 OK\r\n"
|
|
"Server: UPnP/1.0\r\n"
|
|
f"Location: {ip}\r\n"
|
|
f"ST: {BAMBU_SEARCH_TARGET}\r\n"
|
|
f"USN: {self.serial}\r\n"
|
|
"Cache-Control: max-age=1800\r\n"
|
|
f"DevModel.bambu.com: {self.model}\r\n"
|
|
f"DevName.bambu.com: {self.name}\r\n"
|
|
"DevSignal.bambu.com: -44\r\n"
|
|
"DevConnect.bambu.com: lan\r\n"
|
|
"DevBind.bambu.com: free\r\n"
|
|
"Devseclink.bambu.com: secure\r\n"
|
|
"DevInf.bambu.com: eth0\r\n"
|
|
"DevVersion.bambu.com: 01.07.00.00\r\n"
|
|
"DevCap.bambu.com: 1\r\n"
|
|
"\r\n"
|
|
)
|
|
return message.encode()
|
|
|
|
async def start(self) -> None:
|
|
"""Start the SSDP server."""
|
|
if self._running:
|
|
return
|
|
|
|
logger.info("Starting virtual printer SSDP server: %s (%s)", self.name, self.serial)
|
|
self._running = True
|
|
|
|
try:
|
|
# Create UDP socket
|
|
self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
|
self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
|
|
# Try to set SO_REUSEPORT if available
|
|
try:
|
|
self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
|
|
except (AttributeError, OSError):
|
|
pass # SO_REUSEPORT not available on all platforms; non-critical
|
|
|
|
# Set non-blocking mode
|
|
self._socket.setblocking(False)
|
|
|
|
# Bind to SSDP port on specific interface (or all interfaces)
|
|
self._socket.bind((self._bind_ip or "", SSDP_PORT))
|
|
|
|
# Join multicast group (on specific interface if bind_ip is set)
|
|
if self._bind_ip:
|
|
mreq = struct.pack(
|
|
"4s4s",
|
|
socket.inet_aton(SSDP_MULTICAST_ADDR),
|
|
socket.inet_aton(self._bind_ip),
|
|
)
|
|
else:
|
|
mreq = struct.pack("4sl", socket.inet_aton(SSDP_MULTICAST_ADDR), socket.INADDR_ANY)
|
|
self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
|
|
|
|
# Enable broadcast
|
|
self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
|
|
|
# Set multicast TTL
|
|
self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
|
|
|
|
local_ip = self._get_local_ip()
|
|
logger.info("SSDP server listening on port %s, advertising IP: %s", SSDP_PORT, local_ip)
|
|
logger.info("Virtual printer: %s (%s) model=%s", self.name, self.serial, self.model)
|
|
self.ready.set()
|
|
|
|
# Create extra sockets for additional interfaces (VPN, etc.)
|
|
# If no explicit extra interfaces given and we're bound to a
|
|
# specific IP, add a wildcard socket to catch M-SEARCH from
|
|
# other subnets (VPN tunnels, secondary NICs, etc.)
|
|
extra_ips = list(self._extra_interfaces)
|
|
if not extra_ips and self._bind_ip:
|
|
extra_ips.append("0.0.0.0") # nosec B104
|
|
|
|
for iface_ip in extra_ips:
|
|
try:
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
try:
|
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
|
|
except (AttributeError, OSError):
|
|
pass
|
|
sock.setblocking(False)
|
|
sock.bind((iface_ip, SSDP_PORT))
|
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
|
self._extra_sockets.append(sock)
|
|
logger.info("SSDP server also listening on %s:%s", iface_ip, SSDP_PORT)
|
|
except OSError as e:
|
|
logger.warning("SSDP server: failed to bind extra interface %s: %s", iface_ip, e)
|
|
|
|
# Send initial NOTIFY
|
|
await self._send_notify()
|
|
logger.info("Sent initial SSDP NOTIFY announcement")
|
|
|
|
# Run receive and announce loops
|
|
last_notify = asyncio.get_event_loop().time()
|
|
notify_interval = 30.0 # Send NOTIFY every 30 seconds
|
|
|
|
while self._running:
|
|
# Try to receive M-SEARCH requests on primary socket
|
|
try:
|
|
data, addr = self._socket.recvfrom(4096)
|
|
message = data.decode("utf-8", errors="ignore")
|
|
await self._handle_message(message, addr)
|
|
except BlockingIOError:
|
|
pass # No data available on non-blocking socket; will retry
|
|
except OSError as e:
|
|
if self._running:
|
|
logger.debug("SSDP receive error: %s", e)
|
|
|
|
# Try to receive M-SEARCH requests on extra sockets
|
|
for sock in self._extra_sockets:
|
|
try:
|
|
data, addr = sock.recvfrom(4096)
|
|
message = data.decode("utf-8", errors="ignore")
|
|
await self._handle_message(message, addr, sock)
|
|
except BlockingIOError:
|
|
pass
|
|
except OSError:
|
|
pass
|
|
|
|
# Send periodic NOTIFY
|
|
now = asyncio.get_event_loop().time()
|
|
if now - last_notify >= notify_interval:
|
|
await self._send_notify()
|
|
last_notify = now
|
|
|
|
await asyncio.sleep(0.1)
|
|
|
|
except OSError as e:
|
|
if e.errno == 98: # Address already in use
|
|
logger.warning("SSDP port %s in use - real printers may be running", SSDP_PORT)
|
|
else:
|
|
logger.error("SSDP server error: %s", e)
|
|
except asyncio.CancelledError:
|
|
logger.debug("SSDP server cancelled")
|
|
except Exception as e:
|
|
logger.error("SSDP server error: %s", e)
|
|
finally:
|
|
await self._cleanup()
|
|
|
|
async def stop(self) -> None:
|
|
"""Stop the SSDP server."""
|
|
logger.info("Stopping SSDP server")
|
|
self._running = False
|
|
self.ready.clear()
|
|
await self._cleanup()
|
|
|
|
async def _cleanup(self) -> None:
|
|
"""Clean up resources."""
|
|
if self._socket:
|
|
try:
|
|
# Send byebye message
|
|
await self._send_byebye()
|
|
except OSError:
|
|
pass # Best-effort byebye broadcast; socket may already be closed
|
|
|
|
try:
|
|
self._socket.close()
|
|
except OSError:
|
|
pass # Best-effort socket close; may already be released
|
|
self._socket = None
|
|
|
|
for sock in self._extra_sockets:
|
|
try:
|
|
sock.close()
|
|
except OSError:
|
|
pass
|
|
self._extra_sockets = []
|
|
|
|
async def _send_notify(self) -> None:
|
|
"""Send SSDP NOTIFY message via broadcast on all sockets."""
|
|
msg = self._build_notify_message()
|
|
|
|
if self._socket:
|
|
try:
|
|
self._socket.sendto(msg, (SSDP_BROADCAST_ADDR, SSDP_PORT))
|
|
logger.debug(
|
|
"Sent SSDP NOTIFY for %s (Location=%s, USN=%s, bind=%s)",
|
|
self.name,
|
|
self._get_local_ip(),
|
|
self.serial,
|
|
self._bind_ip,
|
|
)
|
|
except OSError as e:
|
|
logger.debug("Failed to send NOTIFY for %s: %s", self.name, e)
|
|
|
|
for sock in self._extra_sockets:
|
|
try:
|
|
sock.sendto(msg, (SSDP_BROADCAST_ADDR, SSDP_PORT))
|
|
except OSError:
|
|
pass # Best-effort broadcast on extra interfaces
|
|
|
|
async def _send_byebye(self) -> None:
|
|
"""Send SSDP byebye message when shutting down."""
|
|
if not self._socket:
|
|
return
|
|
|
|
message = (
|
|
"NOTIFY * HTTP/1.1\r\n"
|
|
f"Host: {SSDP_MULTICAST_ADDR}:1990\r\n"
|
|
f"NT: {BAMBU_SEARCH_TARGET}\r\n"
|
|
"NTS: ssdp:byebye\r\n"
|
|
f"USN: {self.serial}\r\n"
|
|
"\r\n"
|
|
)
|
|
|
|
try:
|
|
self._socket.sendto(message.encode(), (SSDP_BROADCAST_ADDR, SSDP_PORT))
|
|
logger.debug("Sent SSDP byebye")
|
|
except OSError:
|
|
pass # Best-effort byebye send; network may be unavailable during shutdown
|
|
|
|
async def _handle_message(
|
|
self, message: str, addr: tuple[str, int], reply_socket: socket.socket | None = None
|
|
) -> None:
|
|
"""Handle incoming SSDP message.
|
|
|
|
Args:
|
|
message: The SSDP message content
|
|
addr: Tuple of (ip_address, port) of sender
|
|
reply_socket: Socket to send the response on (defaults to primary)
|
|
"""
|
|
# Check if this is an M-SEARCH request for Bambu printers
|
|
if "M-SEARCH" not in message:
|
|
return
|
|
|
|
# Check search target
|
|
if BAMBU_SEARCH_TARGET not in message and "ssdp:all" not in message.lower():
|
|
return
|
|
|
|
logger.debug("Received M-SEARCH from %s", addr[0])
|
|
|
|
# Send response on the socket that received the request
|
|
sock = reply_socket or self._socket
|
|
if sock:
|
|
try:
|
|
response = self._build_response_message()
|
|
sock.sendto(response, addr)
|
|
logger.info(
|
|
"Sent SSDP response to %s for '%s' (Location=%s, USN=%s)",
|
|
addr[0],
|
|
self.name,
|
|
self._get_local_ip(),
|
|
self.serial,
|
|
)
|
|
except OSError as e:
|
|
logger.debug("Failed to send SSDP response for %s: %s", self.name, e)
|
|
|
|
|
|
class SSDPProxy:
|
|
"""SSDP proxy that re-broadcasts printer discovery from one network to another.
|
|
|
|
Listens for SSDP broadcasts from a real printer on the local interface (LAN A),
|
|
then re-broadcasts them on the remote interface (LAN B) with the Location
|
|
header changed to point to Bambuddy's IP on LAN B.
|
|
|
|
This allows Bambu Studio on LAN B to discover the printer via Bambuddy.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
local_interface_ip: str,
|
|
remote_interface_ip: str,
|
|
target_printer_ip: str,
|
|
name: str | None = None,
|
|
):
|
|
"""Initialize the SSDP proxy.
|
|
|
|
Args:
|
|
local_interface_ip: IP of interface on printer's network (LAN A)
|
|
remote_interface_ip: IP of interface on slicer's network (LAN B)
|
|
target_printer_ip: IP of the real printer to proxy SSDP for
|
|
name: Optional VP name to advertise (replaces printer's real name)
|
|
"""
|
|
self.local_interface_ip = local_interface_ip
|
|
self.remote_interface_ip = remote_interface_ip
|
|
self.target_printer_ip = target_printer_ip
|
|
self.proxy_name = name
|
|
self._running = False
|
|
self._local_socket: socket.socket | None = None
|
|
self._remote_socket: socket.socket | None = None
|
|
self._last_printer_ssdp: bytes | None = None
|
|
self._printer_info: dict[str, str] = {}
|
|
|
|
def _parse_ssdp_message(self, data: bytes) -> dict[str, str]:
|
|
"""Parse SSDP message into header dict."""
|
|
headers = {}
|
|
try:
|
|
text = data.decode("utf-8", errors="ignore")
|
|
for line in text.split("\r\n"):
|
|
if ":" in line:
|
|
key, value = line.split(":", 1)
|
|
headers[key.strip().lower()] = value.strip()
|
|
except Exception:
|
|
pass # Return partial headers if parsing fails; malformed packets are common
|
|
return headers
|
|
|
|
def _rewrite_ssdp(self, data: bytes) -> bytes:
|
|
"""Rewrite SSDP message for proxy re-broadcast.
|
|
|
|
- Location: changed to Bambuddy's remote interface IP
|
|
- DevBind: forced to 'free' so the slicer treats the proxy as a
|
|
LAN-only printer (avoids cloud auth requirement for sending prints)
|
|
"""
|
|
try:
|
|
text = data.decode("utf-8", errors="ignore")
|
|
original = text
|
|
# Replace Location header with our remote interface IP
|
|
text = re.sub(
|
|
r"(Location:\s*)[\d.]+",
|
|
f"\\g<1>{self.remote_interface_ip}",
|
|
text,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
# Force DevBind to 'free' - ensures slicer uses LAN mode for
|
|
# both monitoring AND sending prints through the proxy
|
|
text = re.sub(
|
|
r"(DevBind\.bambu\.com:\s*)\S+",
|
|
r"\g<1>free",
|
|
text,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
# Replace printer name with configured VP name, or append " - Proxy"
|
|
if self.proxy_name:
|
|
text = re.sub(
|
|
r"(DevName\.bambu\.com:\s*)[^\r\n]+",
|
|
rf"\g<1>{self.proxy_name}",
|
|
text,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
else:
|
|
text = re.sub(
|
|
r"(DevName\.bambu\.com:\s*)([^\r\n]+)",
|
|
r"\g<1>\g<2> - Proxy",
|
|
text,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
if text != original:
|
|
logger.debug("Rewrote SSDP for proxy:\n%s", text)
|
|
else:
|
|
logger.warning("SSDP rewrite had no effect. Packet:\n%s", original)
|
|
return text.encode("utf-8")
|
|
except Exception as e:
|
|
logger.error("Failed to rewrite SSDP: %s", e)
|
|
return data
|
|
|
|
async def start(self) -> None:
|
|
"""Start the SSDP proxy."""
|
|
if self._running:
|
|
return
|
|
|
|
logger.info(
|
|
f"Starting SSDP proxy: listening on {self.local_interface_ip} (LAN A), "
|
|
f"broadcasting on {self.remote_interface_ip} (LAN B), "
|
|
f"proxying printer {self.target_printer_ip}"
|
|
)
|
|
self._running = True
|
|
|
|
try:
|
|
# Create socket for listening on LAN A (printer network)
|
|
# Bind to 0.0.0.0 to receive broadcast packets (255.255.255.255)
|
|
# We filter by source IP in the handler
|
|
self._local_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
|
self._local_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
try:
|
|
self._local_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
|
|
except (AttributeError, OSError):
|
|
pass # SO_REUSEPORT not available on all platforms; non-critical
|
|
self._local_socket.setblocking(False)
|
|
# Bind to all interfaces to receive broadcasts
|
|
self._local_socket.bind(("", SSDP_PORT))
|
|
|
|
# Join multicast group on local interface (for multicast SSDP if used)
|
|
mreq = struct.pack(
|
|
"4s4s",
|
|
socket.inet_aton(SSDP_MULTICAST_ADDR),
|
|
socket.inet_aton(self.local_interface_ip),
|
|
)
|
|
self._local_socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
|
|
self._local_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
|
|
|
# Create socket for broadcasting on LAN B (slicer network)
|
|
self._remote_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
|
self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
try:
|
|
self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
|
|
except (AttributeError, OSError):
|
|
pass # SO_REUSEPORT not available on all platforms; non-critical
|
|
self._remote_socket.setblocking(False)
|
|
# Bind to remote interface
|
|
self._remote_socket.bind((self.remote_interface_ip, 0))
|
|
self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
|
|
|
logger.info(
|
|
"SSDP proxy listening on 0.0.0.0:%s (filtering for printer %s)", SSDP_PORT, self.target_printer_ip
|
|
)
|
|
logger.info("SSDP proxy will broadcast on %s", self.remote_interface_ip)
|
|
|
|
# Main loop
|
|
last_broadcast = 0.0
|
|
broadcast_interval = 30.0 # Re-broadcast every 30 seconds
|
|
|
|
while self._running:
|
|
# Listen for SSDP from printer on LAN A
|
|
try:
|
|
data, addr = self._local_socket.recvfrom(4096)
|
|
await self._handle_local_packet(data, addr)
|
|
except BlockingIOError:
|
|
pass # No data available on non-blocking socket; will retry
|
|
except OSError as e:
|
|
if self._running:
|
|
logger.debug("SSDP proxy receive error: %s", e)
|
|
|
|
# Listen for M-SEARCH from slicer on LAN B (via remote socket would need separate bind)
|
|
# For now, we periodically re-broadcast cached printer SSDP
|
|
now = asyncio.get_event_loop().time()
|
|
if self._last_printer_ssdp and now - last_broadcast >= broadcast_interval:
|
|
await self._broadcast_to_remote()
|
|
last_broadcast = now
|
|
|
|
await asyncio.sleep(0.1)
|
|
|
|
except OSError as e:
|
|
logger.error("SSDP proxy error: %s", e)
|
|
except asyncio.CancelledError:
|
|
logger.debug("SSDP proxy cancelled")
|
|
except Exception as e:
|
|
logger.error("SSDP proxy error: %s", e)
|
|
finally:
|
|
await self._cleanup()
|
|
|
|
async def stop(self) -> None:
|
|
"""Stop the SSDP proxy."""
|
|
logger.info("Stopping SSDP proxy")
|
|
self._running = False
|
|
await self._cleanup()
|
|
|
|
async def _cleanup(self) -> None:
|
|
"""Clean up resources."""
|
|
for sock in [self._local_socket, self._remote_socket]:
|
|
if sock:
|
|
try:
|
|
sock.close()
|
|
except OSError:
|
|
pass # Best-effort socket close; may already be released
|
|
self._local_socket = None
|
|
self._remote_socket = None
|
|
|
|
async def _handle_local_packet(self, data: bytes, addr: tuple[str, int]) -> None:
|
|
"""Handle SSDP packet received on local interface (LAN A).
|
|
|
|
Processes two types of traffic:
|
|
- NOTIFY from the real printer → cache and re-broadcast on LAN B
|
|
- M-SEARCH from slicers on LAN B → respond with cached printer info
|
|
"""
|
|
sender_ip = addr[0]
|
|
|
|
# Ignore packets from our own interfaces (prevent loops)
|
|
if sender_ip in (self.local_interface_ip, self.remote_interface_ip):
|
|
return
|
|
|
|
# Handle M-SEARCH from slicers (any IP that's not the target printer)
|
|
if sender_ip != self.target_printer_ip:
|
|
if b"M-SEARCH" in data:
|
|
await self._respond_to_msearch(data, addr)
|
|
return
|
|
|
|
# Below: NOTIFY handling from the real printer
|
|
|
|
# Check if it's a NOTIFY message
|
|
if b"NOTIFY" not in data and b"HTTP/1.1 200" not in data:
|
|
return
|
|
|
|
# Check if it's a Bambu printer SSDP
|
|
if b"bambulab-com:device:3dprinter" not in data:
|
|
return
|
|
|
|
# Parse and store printer info
|
|
headers = self._parse_ssdp_message(data)
|
|
if headers:
|
|
self._printer_info = headers
|
|
logger.debug("Received SSDP from printer %s: %s", sender_ip, headers.get("devname.bambu.com", "unknown"))
|
|
|
|
# Store and immediately broadcast
|
|
self._last_printer_ssdp = data
|
|
await self._broadcast_to_remote()
|
|
|
|
async def _respond_to_msearch(self, data: bytes, addr: tuple[str, int]) -> None:
|
|
"""Respond to M-SEARCH from a slicer with cached, rewritten printer info.
|
|
|
|
When Bambu Studio sends an M-SEARCH (e.g., before sending a print),
|
|
we respond with the cached printer info, rewritten to point to the
|
|
proxy's LAN B IP. Without this, the slicer thinks the printer is
|
|
offline and shows a 'connect to printer' modal.
|
|
"""
|
|
# Check if it's a relevant M-SEARCH
|
|
if b"bambulab-com:device:3dprinter" not in data and b"ssdp:all" not in data.lower():
|
|
return
|
|
|
|
if not self._last_printer_ssdp:
|
|
logger.debug("M-SEARCH from %s but no cached printer SSDP yet", addr[0])
|
|
return
|
|
|
|
logger.debug("Received M-SEARCH from slicer %s", addr[0])
|
|
|
|
# Rewrite the cached printer SSDP (Location → proxy IP, DevBind → free)
|
|
rewritten = self._rewrite_ssdp(self._last_printer_ssdp)
|
|
text = rewritten.decode("utf-8", errors="ignore")
|
|
|
|
# Convert NOTIFY format to M-SEARCH response format:
|
|
# "NOTIFY * HTTP/1.1" → "HTTP/1.1 200 OK"
|
|
# NT: → ST: (Notification Type → Search Target)
|
|
# Remove NTS: header (only in NOTIFY)
|
|
text = re.sub(r"^NOTIFY \* HTTP/1\.1", "HTTP/1.1 200 OK", text)
|
|
text = re.sub(r"^NT:", "ST:", text, flags=re.MULTILINE)
|
|
text = re.sub(r"^NTS:.*\r\n", "", text, flags=re.MULTILINE)
|
|
|
|
# Send unicast response directly to the slicer via remote socket
|
|
if self._remote_socket:
|
|
try:
|
|
self._remote_socket.sendto(text.encode("utf-8"), addr)
|
|
logger.info("Sent SSDP M-SEARCH response to %s", addr[0])
|
|
except OSError as e:
|
|
logger.debug("Failed to send M-SEARCH response to %s: %s", addr[0], e)
|
|
|
|
async def _broadcast_to_remote(self) -> None:
|
|
"""Broadcast cached printer SSDP on remote interface (LAN B)."""
|
|
if not self._remote_socket or not self._last_printer_ssdp:
|
|
return
|
|
|
|
try:
|
|
# Rewrite Location to point to Bambuddy's remote interface
|
|
rewritten = self._rewrite_ssdp(self._last_printer_ssdp)
|
|
|
|
# Calculate broadcast address for remote network
|
|
# Use 255.255.255.255 for simplicity (works across subnets)
|
|
self._remote_socket.sendto(rewritten, (SSDP_BROADCAST_ADDR, SSDP_PORT))
|
|
|
|
printer_name = self._printer_info.get("devname.bambu.com", "unknown")
|
|
logger.debug("Broadcast SSDP for '%s' on LAN B (%s)", printer_name, self.remote_interface_ip)
|
|
except OSError as e:
|
|
logger.debug("Failed to broadcast SSDP on remote: %s", e)
|