mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
- Added printer auto discovery to add printer modal
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
Printer discovery API endpoints.
|
||||
|
||||
Provides endpoints for discovering Bambu Lab printers on the local network.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.app.services.discovery import discovery_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/discovery", tags=["discovery"])
|
||||
|
||||
|
||||
class DiscoveryStatus(BaseModel):
|
||||
"""Discovery status response."""
|
||||
|
||||
running: bool
|
||||
|
||||
|
||||
class DiscoveredPrinterResponse(BaseModel):
|
||||
"""Discovered printer response."""
|
||||
|
||||
serial: str
|
||||
name: str
|
||||
ip_address: str
|
||||
model: str | None = None
|
||||
discovered_at: str | None = None
|
||||
|
||||
|
||||
@router.get("/status", response_model=DiscoveryStatus)
|
||||
async def get_discovery_status():
|
||||
"""Get the current discovery status."""
|
||||
return DiscoveryStatus(running=discovery_service.is_running)
|
||||
|
||||
|
||||
@router.post("/start", response_model=DiscoveryStatus)
|
||||
async def start_discovery(duration: float = 10.0):
|
||||
"""Start printer discovery.
|
||||
|
||||
Args:
|
||||
duration: Discovery duration in seconds (default 10)
|
||||
"""
|
||||
await discovery_service.start(duration=duration)
|
||||
return DiscoveryStatus(running=discovery_service.is_running)
|
||||
|
||||
|
||||
@router.post("/stop", response_model=DiscoveryStatus)
|
||||
async def stop_discovery():
|
||||
"""Stop printer discovery."""
|
||||
await discovery_service.stop()
|
||||
return DiscoveryStatus(running=discovery_service.is_running)
|
||||
|
||||
|
||||
@router.get("/printers", response_model=list[DiscoveredPrinterResponse])
|
||||
async def get_discovered_printers():
|
||||
"""Get list of discovered printers."""
|
||||
return [
|
||||
DiscoveredPrinterResponse(
|
||||
serial=p.serial,
|
||||
name=p.name,
|
||||
ip_address=p.ip_address,
|
||||
model=p.model,
|
||||
discovered_at=p.discovered_at,
|
||||
)
|
||||
for p in discovery_service.discovered_printers
|
||||
]
|
||||
@@ -56,6 +56,7 @@ from backend.app.api.routes import (
|
||||
archives,
|
||||
camera,
|
||||
cloud,
|
||||
discovery,
|
||||
external_links,
|
||||
filaments,
|
||||
kprofiles,
|
||||
@@ -1530,6 +1531,7 @@ app.include_router(webhook.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(ams_history.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(system.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(websocket.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(discovery.router, prefix=app_settings.api_prefix)
|
||||
|
||||
|
||||
# Serve static files (React build)
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
Bambu Lab printer discovery service using SSDP.
|
||||
|
||||
Bambu Lab printers advertise themselves via SSDP (Simple Service Discovery Protocol)
|
||||
on the local network. This service listens for these advertisements and provides
|
||||
a list of discovered printers.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# SSDP multicast address - Bambu uses port 2021, not standard 1900
|
||||
SSDP_ADDR = "239.255.255.250"
|
||||
SSDP_PORT = 2021 # Bambu Lab uses non-standard port
|
||||
|
||||
# Bambu Lab SSDP search target
|
||||
BAMBU_SEARCH_TARGET = "urn:bambulab-com:device:3dprinter:1"
|
||||
|
||||
# SSDP M-SEARCH message
|
||||
SSDP_MSEARCH = (
|
||||
"M-SEARCH * HTTP/1.1\r\n"
|
||||
f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n"
|
||||
'MAN: "ssdp:discover"\r\n'
|
||||
"MX: 3\r\n"
|
||||
f"ST: {BAMBU_SEARCH_TARGET}\r\n"
|
||||
"\r\n"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiscoveredPrinter:
|
||||
"""Represents a discovered Bambu Lab printer."""
|
||||
|
||||
serial: str
|
||||
name: str
|
||||
ip_address: str
|
||||
model: str | None = None
|
||||
discovered_at: str | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"serial": self.serial,
|
||||
"name": self.name,
|
||||
"ip_address": self.ip_address,
|
||||
"model": self.model,
|
||||
"discovered_at": self.discovered_at,
|
||||
}
|
||||
|
||||
|
||||
class PrinterDiscoveryService:
|
||||
"""Service for discovering Bambu Lab printers on the network."""
|
||||
|
||||
def __init__(self):
|
||||
self._discovered: dict[str, DiscoveredPrinter] = {}
|
||||
self._running = False
|
||||
self._task: asyncio.Task | None = None
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._running
|
||||
|
||||
@property
|
||||
def discovered_printers(self) -> list[DiscoveredPrinter]:
|
||||
return list(self._discovered.values())
|
||||
|
||||
def clear(self):
|
||||
"""Clear discovered printers."""
|
||||
self._discovered.clear()
|
||||
|
||||
async def start(self, duration: float = 10.0):
|
||||
"""Start discovery for a specified duration."""
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._discovered.clear()
|
||||
self._task = asyncio.create_task(self._discover(duration))
|
||||
|
||||
async def stop(self):
|
||||
"""Stop discovery."""
|
||||
self._running = False
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
async def _discover(self, duration: float):
|
||||
"""Run discovery for the specified duration.
|
||||
|
||||
Bambu printers broadcast NOTIFY messages periodically on port 2021.
|
||||
We need to bind to that port and listen for broadcasts.
|
||||
"""
|
||||
sock = None
|
||||
try:
|
||||
# Create UDP socket for SSDP
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
|
||||
# Try to set SO_REUSEPORT if available (Linux/macOS)
|
||||
try:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
|
||||
# Set non-blocking mode
|
||||
sock.setblocking(False)
|
||||
|
||||
# Bind to the SSDP port to receive NOTIFY broadcasts from printers
|
||||
sock.bind(("", SSDP_PORT))
|
||||
|
||||
# Join multicast group to receive multicast messages
|
||||
mreq = struct.pack("4sl", socket.inet_aton(SSDP_ADDR), socket.INADDR_ANY)
|
||||
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
|
||||
|
||||
# Enable broadcast
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
|
||||
logger.info(f"Starting SSDP discovery on port {SSDP_PORT} for Bambu Lab printers...")
|
||||
|
||||
# Send initial M-SEARCH request to trigger responses
|
||||
try:
|
||||
sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT))
|
||||
except Exception as e:
|
||||
logger.debug(f"M-SEARCH send error: {e}")
|
||||
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
last_send = start_time
|
||||
|
||||
while self._running and (asyncio.get_event_loop().time() - start_time) < duration:
|
||||
# Try to receive data
|
||||
try:
|
||||
data, addr = sock.recvfrom(4096)
|
||||
message = data.decode("utf-8", errors="ignore")
|
||||
logger.debug(f"Received from {addr[0]}: {message[:100]}...")
|
||||
self._handle_response(message, addr[0])
|
||||
except BlockingIOError:
|
||||
# No data available, that's fine
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug(f"SSDP receive error: {e}")
|
||||
|
||||
# Re-send M-SEARCH every 3 seconds
|
||||
now = asyncio.get_event_loop().time()
|
||||
if now - last_send >= 3.0:
|
||||
try:
|
||||
sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT))
|
||||
last_send = now
|
||||
except Exception as e:
|
||||
logger.debug(f"SSDP send error: {e}")
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
logger.info(f"Discovery complete. Found {len(self._discovered)} printers.")
|
||||
|
||||
except OSError as e:
|
||||
if e.errno == 98: # Address already in use
|
||||
logger.warning(f"Port {SSDP_PORT} is in use, trying alternative discovery...")
|
||||
await self._discover_alternative(duration)
|
||||
else:
|
||||
logger.error(f"Discovery error: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Discovery error: {e}")
|
||||
finally:
|
||||
self._running = False
|
||||
if sock:
|
||||
try:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _discover_alternative(self, duration: float):
|
||||
"""Alternative discovery using a random port (less reliable)."""
|
||||
sock = None
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.setblocking(False)
|
||||
sock.bind(("", 0))
|
||||
|
||||
# Join multicast group
|
||||
mreq = struct.pack("4sl", socket.inet_aton(SSDP_ADDR), socket.INADDR_ANY)
|
||||
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
|
||||
logger.info("Using alternative discovery method...")
|
||||
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
last_send = start_time
|
||||
|
||||
while self._running and (asyncio.get_event_loop().time() - start_time) < duration:
|
||||
try:
|
||||
data, addr = sock.recvfrom(4096)
|
||||
self._handle_response(data.decode("utf-8", errors="ignore"), addr[0])
|
||||
except BlockingIOError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug(f"SSDP receive error: {e}")
|
||||
|
||||
now = asyncio.get_event_loop().time()
|
||||
if now - last_send >= 2.0:
|
||||
try:
|
||||
sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT))
|
||||
last_send = now
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
logger.info(f"Alternative discovery complete. Found {len(self._discovered)} printers.")
|
||||
except Exception as e:
|
||||
logger.error(f"Alternative discovery error: {e}")
|
||||
finally:
|
||||
if sock:
|
||||
try:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _handle_response(self, response: str, ip_address: str):
|
||||
"""Parse SSDP response and extract printer info."""
|
||||
# Check if it's a Bambu Lab printer response
|
||||
if BAMBU_SEARCH_TARGET not in response and "bambulab" not in response.lower():
|
||||
logger.debug(f"Ignoring non-Bambu response from {ip_address}")
|
||||
return
|
||||
|
||||
# Extract USN (Unique Service Name) which contains the serial
|
||||
# Bambu format is just "USN: SERIALNUMBER" (no uuid: prefix)
|
||||
usn_match = re.search(r"USN:\s*(?:uuid:)?([^\s\r\n]+)", response, re.IGNORECASE)
|
||||
if not usn_match:
|
||||
logger.debug(f"No USN found in response from {ip_address}")
|
||||
return
|
||||
|
||||
serial = usn_match.group(1).strip()
|
||||
|
||||
# Extract device name from LOCATION or DevName header
|
||||
name = serial # Default to serial if no name found
|
||||
name_match = re.search(r"DevName\.bambu\.com:\s*(.+?)(?:\r\n|\n|$)", response, re.IGNORECASE)
|
||||
if name_match:
|
||||
name = name_match.group(1).strip()
|
||||
|
||||
# Try to extract model from DevModel header
|
||||
model = None
|
||||
model_match = re.search(r"DevModel\.bambu\.com:\s*(.+?)(?:\r\n|\n|$)", response, re.IGNORECASE)
|
||||
if model_match:
|
||||
model = model_match.group(1).strip()
|
||||
|
||||
# Also try NT header for model
|
||||
if not model:
|
||||
nt_match = re.search(r"NT:\s*urn:bambulab-com:device:([^:]+)", response, re.IGNORECASE)
|
||||
if nt_match:
|
||||
model = nt_match.group(1).strip()
|
||||
|
||||
# Skip if already discovered
|
||||
if serial in self._discovered:
|
||||
return
|
||||
|
||||
printer = DiscoveredPrinter(
|
||||
serial=serial,
|
||||
name=name,
|
||||
ip_address=ip_address,
|
||||
model=model,
|
||||
discovered_at=datetime.now().isoformat(),
|
||||
)
|
||||
|
||||
self._discovered[serial] = printer
|
||||
logger.info(f"Discovered printer: {name} ({serial}) at {ip_address}")
|
||||
|
||||
|
||||
# Global discovery service instance
|
||||
discovery_service = PrinterDiscoveryService()
|
||||
@@ -2016,3 +2016,30 @@ export interface SystemInfo {
|
||||
percent: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Discovery types
|
||||
export interface DiscoveredPrinter {
|
||||
serial: string;
|
||||
name: string;
|
||||
ip_address: string;
|
||||
model: string | null;
|
||||
discovered_at: string | null;
|
||||
}
|
||||
|
||||
export interface DiscoveryStatus {
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
// Discovery API
|
||||
export const discoveryApi = {
|
||||
getStatus: () => request<DiscoveryStatus>('/discovery/status'),
|
||||
|
||||
startDiscovery: (duration: number = 10) =>
|
||||
request<DiscoveryStatus>(`/discovery/start?duration=${duration}`, { method: 'POST' }),
|
||||
|
||||
stopDiscovery: () =>
|
||||
request<DiscoveryStatus>('/discovery/stop', { method: 'POST' }),
|
||||
|
||||
getDiscoveredPrinters: () =>
|
||||
request<DiscoveredPrinter[]>('/discovery/printers'),
|
||||
};
|
||||
|
||||
@@ -26,10 +26,12 @@ import {
|
||||
LayoutList,
|
||||
Layers,
|
||||
Video,
|
||||
Search,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import type { Printer, PrinterCreate, AMSUnit } from '../api/client';
|
||||
import { api, discoveryApi } from '../api/client';
|
||||
import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter } from '../api/client';
|
||||
import { Card, CardContent } from '../components/Card';
|
||||
import { Button } from '../components/Button';
|
||||
import { ConfirmModal } from '../components/ConfirmModal';
|
||||
@@ -1425,9 +1427,11 @@ function PrinterCard({
|
||||
function AddPrinterModal({
|
||||
onClose,
|
||||
onAdd,
|
||||
existingSerials,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onAdd: (data: PrinterCreate) => void;
|
||||
existingSerials: string[];
|
||||
}) {
|
||||
const [form, setForm] = useState<PrinterCreate>({
|
||||
name: '',
|
||||
@@ -1438,6 +1442,114 @@ function AddPrinterModal({
|
||||
auto_archive: true,
|
||||
});
|
||||
|
||||
// Discovery state
|
||||
const [discovering, setDiscovering] = useState(false);
|
||||
const [discovered, setDiscovered] = useState<DiscoveredPrinter[]>([]);
|
||||
const [discoveryError, setDiscoveryError] = useState('');
|
||||
const [hasScanned, setHasScanned] = useState(false);
|
||||
|
||||
// Filter out already-added printers
|
||||
const newPrinters = discovered.filter(p => !existingSerials.includes(p.serial));
|
||||
|
||||
const startDiscovery = async () => {
|
||||
setDiscoveryError('');
|
||||
setDiscovered([]);
|
||||
setDiscovering(true);
|
||||
setHasScanned(false);
|
||||
|
||||
try {
|
||||
await discoveryApi.startDiscovery(10);
|
||||
|
||||
// Poll for discovered printers every second
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const printers = await discoveryApi.getDiscoveredPrinters();
|
||||
setDiscovered(printers);
|
||||
} catch (e) {
|
||||
console.error('Failed to get discovered printers:', e);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Stop after 10 seconds
|
||||
setTimeout(async () => {
|
||||
clearInterval(pollInterval);
|
||||
try {
|
||||
await discoveryApi.stopDiscovery();
|
||||
} catch (e) {
|
||||
// Ignore stop errors
|
||||
}
|
||||
setDiscovering(false);
|
||||
setHasScanned(true);
|
||||
// Final fetch
|
||||
try {
|
||||
const printers = await discoveryApi.getDiscoveredPrinters();
|
||||
setDiscovered(printers);
|
||||
} catch (e) {
|
||||
console.error('Failed to get final discovered printers:', e);
|
||||
}
|
||||
}, 10000);
|
||||
} catch (e) {
|
||||
console.error('Failed to start discovery:', e);
|
||||
setDiscoveryError(e instanceof Error ? e.message : 'Failed to start discovery');
|
||||
setDiscovering(false);
|
||||
setHasScanned(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Map SSDP model codes to dropdown values
|
||||
const mapModelCode = (ssdpModel: string | null): string => {
|
||||
if (!ssdpModel) return '';
|
||||
const modelMap: Record<string, string> = {
|
||||
// H2 Series
|
||||
'O1D': 'H2D',
|
||||
'O1C': 'H2C',
|
||||
'O1S': 'H2S',
|
||||
// X1 Series
|
||||
'BL-P001': 'X1C',
|
||||
'BL-P002': 'X1',
|
||||
'BL-P003': 'X1E',
|
||||
// P Series
|
||||
'C11': 'P1S',
|
||||
'C12': 'P1P',
|
||||
'C13': 'P2S',
|
||||
// A1 Series
|
||||
'N2S': 'A1',
|
||||
'N1': 'A1 Mini',
|
||||
// Direct matches
|
||||
'X1C': 'X1C',
|
||||
'X1': 'X1',
|
||||
'X1E': 'X1E',
|
||||
'P1S': 'P1S',
|
||||
'P1P': 'P1P',
|
||||
'P2S': 'P2S',
|
||||
'A1': 'A1',
|
||||
'A1 Mini': 'A1 Mini',
|
||||
'H2D': 'H2D',
|
||||
'H2C': 'H2C',
|
||||
'H2S': 'H2S',
|
||||
};
|
||||
return modelMap[ssdpModel] || ssdpModel;
|
||||
};
|
||||
|
||||
const selectPrinter = (printer: DiscoveredPrinter) => {
|
||||
setForm({
|
||||
...form,
|
||||
name: printer.name || '',
|
||||
serial_number: printer.serial,
|
||||
ip_address: printer.ip_address,
|
||||
model: mapModelCode(printer.model),
|
||||
});
|
||||
// Clear discovery results after selection
|
||||
setDiscovered([]);
|
||||
};
|
||||
|
||||
// Cleanup discovery on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
discoveryApi.stopDiscovery().catch(() => {});
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -1455,6 +1567,73 @@ function AddPrinterModal({
|
||||
<Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<CardContent>
|
||||
<h2 className="text-xl font-semibold mb-4">Add Printer</h2>
|
||||
|
||||
{/* Discovery Section */}
|
||||
<div className="mb-4 pb-4 border-b border-bambu-dark-tertiary">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={startDiscovery}
|
||||
disabled={discovering}
|
||||
className="w-full"
|
||||
>
|
||||
{discovering ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Scanning...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className="w-4 h-4" />
|
||||
Discover Printers on Network
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{discoveryError && (
|
||||
<div className="mt-2 text-sm text-red-400">{discoveryError}</div>
|
||||
)}
|
||||
|
||||
{newPrinters.length > 0 && (
|
||||
<div className="mt-3 space-y-2 max-h-40 overflow-y-auto">
|
||||
{newPrinters.map((printer) => (
|
||||
<div
|
||||
key={printer.serial}
|
||||
className="flex items-center justify-between p-2 bg-bambu-dark rounded-lg hover:bg-bambu-dark-secondary cursor-pointer transition-colors"
|
||||
onClick={() => selectPrinter(printer)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-white text-sm truncate">
|
||||
{printer.name || printer.serial}
|
||||
</p>
|
||||
<p className="text-xs text-bambu-gray truncate">
|
||||
{printer.model || 'Unknown'} • {printer.ip_address}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown className="w-4 h-4 text-bambu-gray -rotate-90 flex-shrink-0 ml-2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{discovering && (
|
||||
<p className="mt-2 text-sm text-bambu-gray text-center">
|
||||
Scanning network...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hasScanned && !discovering && discovered.length === 0 && (
|
||||
<p className="mt-2 text-sm text-bambu-gray text-center">
|
||||
No printers found on the network.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hasScanned && !discovering && discovered.length > 0 && newPrinters.length === 0 && (
|
||||
<p className="mt-2 text-sm text-bambu-gray text-center">
|
||||
All discovered printers are already configured.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -2152,6 +2331,7 @@ export function PrintersPage() {
|
||||
<AddPrinterModal
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onAdd={(data) => addMutation.mutate(data)}
|
||||
existingSerials={printers?.map(p => p.serial_number) || []}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -23,7 +23,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-D1Mr2O8s.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BoyoYwvX.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CbCN6LSA.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user