Skip to content

Commit 6c23cf3

Browse files
committed
feat: implement comprehensive printer discovery and connection system
This implementation adds full WebUI-based printer discovery, connection, and management capabilities, bridging the gap that previously prevented proper printer connections via CLI flags. Backend Changes: - Enhanced ConnectionEstablishmentService with timeout handling (10s), retry logic (3 attempts with exponential backoff), and improved error logging to fix CLI connection hangs - Added discovery API routes (/api/discovery/scan, /api/discovery/scan-ip, /api/discovery/status, /api/discovery/cancel) for network scanning - Added printer management routes (/api/printers/connect, /api/printers/disconnect, /api/printers/saved, /api/printers/reconnect) - Integrated services with proper error handling and validation Frontend Changes: - Created printer-discovery feature with tabbed modal interface: * Network Scan tab: Auto-discover printers on local network * Manual Entry tab: Direct IP connection with printer type selection * Saved Printers tab: Reconnect to previously connected printers - Added "Add Printer" button to header for easy access - Implemented real-time discovery status updates and spinner - Added printer card UI with connection status badges - Full support for both 5M/Pro (new API) and legacy printers Key Features: - Network-wide printer discovery with IP matching - Saved printer management (reconnect, delete) - Smart printer type detection (5M family vs legacy) - Check code validation for 5M/Pro printers - IP address change detection for saved printers - Comprehensive error handling and user feedback This resolves the issue where CLI connections would hang during handshake and enables full discovery/connection workflows from the WebUI, allowing users to discover printers, match IPs, fetch serials for authentication, and establish connections without CLI flags.
1 parent 27dbc70 commit 6c23cf3

8 files changed

Lines changed: 1286 additions & 66 deletions

File tree

src/services/ConnectionEstablishmentService.ts

Lines changed: 151 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -60,84 +60,169 @@ export class ConnectionEstablishmentService extends EventEmitter {
6060
/**
6161
* Create temporary connection to determine printer type
6262
* Uses legacy API for universal compatibility
63+
* Includes timeout handling and retry logic
6364
*/
64-
public async createTemporaryConnection(printer: DiscoveredPrinter): Promise<TemporaryConnectionResult> {
65+
public async createTemporaryConnection(
66+
printer: DiscoveredPrinter,
67+
timeout = 10000,
68+
retries = 3
69+
): Promise<TemporaryConnectionResult> {
6570
this.emit('temporary-connection-started', printer);
6671

67-
try {
68-
// Always use legacy API for type detection
69-
const tempClient = new FlashForgeClient(printer.ipAddress);
70-
const connected = await tempClient.initControl();
72+
for (let attempt = 1; attempt <= retries; attempt++) {
73+
let tempClient: FlashForgeClient | null = null;
7174

72-
if (!connected) {
73-
this.emit('temporary-connection-failed', 'Failed to establish temporary connection');
74-
return {
75-
success: false,
76-
error: 'Failed to establish temporary connection'
77-
};
78-
}
75+
try {
76+
console.log(`[Connection] Attempt ${attempt}/${retries} for ${printer.ipAddress}`);
7977

80-
// Get printer info to determine type
81-
const printerInfo = await tempClient.getPrinterInfo();
82-
if (!printerInfo || !printerInfo.TypeName) {
83-
void tempClient.dispose();
84-
this.emit('temporary-connection-failed', 'Failed to get printer type information');
85-
return {
86-
success: false,
87-
error: 'Failed to get printer type information'
88-
};
89-
}
78+
// Always use legacy API for type detection
79+
tempClient = new FlashForgeClient(printer.ipAddress);
9080

91-
const typeName = printerInfo.TypeName;
92-
const familyInfo = detectPrinterFamily(typeName);
93-
94-
console.log('Temporary connection - extracted printer info:', {
95-
TypeName: printerInfo.TypeName,
96-
Name: printerInfo.Name,
97-
SerialNumber: printerInfo.SerialNumber,
98-
is5MFamily: familyInfo.is5MFamily
99-
});
100-
101-
this.emit('printer-type-detected', { typeName, familyInfo });
102-
103-
// For legacy printers, we can reuse this connection
104-
if (!familyInfo.is5MFamily) {
105-
return {
106-
success: true,
107-
typeName,
108-
printerInfo: {
109-
...(printerInfo as unknown as Record<string, unknown>),
110-
_reuseableClient: tempClient // Store for reuse
81+
// Wrap initControl in timeout
82+
console.log(`[Connection] Initializing control connection (timeout: ${timeout}ms)...`);
83+
const connected = await Promise.race([
84+
tempClient.initControl(),
85+
new Promise<boolean>((_, reject) =>
86+
setTimeout(() => reject(new Error('Connection timeout')), timeout)
87+
)
88+
]);
89+
90+
if (!connected) {
91+
console.error(`[Connection] initControl returned false for ${printer.ipAddress}`);
92+
if (tempClient) {
93+
try {
94+
void tempClient.dispose();
95+
} catch (disposeError) {
96+
console.error('[Connection] Error disposing temp client after initControl failure:', disposeError);
97+
}
11198
}
112-
};
113-
} else {
114-
// 5M family - dispose temp client, will create new one
115-
// But first ensure we have critical information for dual API connection
116-
if (!printerInfo.SerialNumber || printerInfo.SerialNumber.trim() === '') {
117-
console.warn('Warning: No serial number found in printer info for 5M family printer');
118-
console.warn('This may cause dual API connection to fail');
99+
100+
if (attempt < retries) {
101+
await this.delay(1000 * attempt); // Exponential backoff
102+
continue;
103+
}
104+
105+
this.emit('temporary-connection-failed', 'Failed to initialize control');
106+
return {
107+
success: false,
108+
error: 'Failed to initialize control'
109+
};
110+
}
111+
112+
console.log(`[Connection] Control initialized, fetching printer info (timeout: ${timeout}ms)...`);
113+
114+
// Get printer info with timeout
115+
const printerInfo = await Promise.race([
116+
tempClient.getPrinterInfo(),
117+
new Promise<never>((_, reject) =>
118+
setTimeout(() => reject(new Error('Printer info timeout')), timeout)
119+
)
120+
]);
121+
122+
if (!printerInfo || !printerInfo.TypeName) {
123+
console.error(`[Connection] Invalid printer info received`);
124+
if (tempClient) {
125+
void tempClient.dispose();
126+
}
127+
128+
if (attempt < retries) {
129+
await this.delay(1000 * attempt);
130+
continue;
131+
}
132+
133+
this.emit('temporary-connection-failed', 'Failed to get printer type information');
134+
return {
135+
success: false,
136+
error: 'Failed to get printer type information'
137+
};
119138
}
120-
121-
void tempClient.dispose();
122-
123-
// Add a small delay after disposing temp client to ensure clean state
124-
await new Promise(resolve => setTimeout(resolve, 200));
125-
139+
140+
const typeName = printerInfo.TypeName;
141+
const familyInfo = detectPrinterFamily(typeName);
142+
143+
console.log('[Connection] Temporary connection successful - extracted printer info:', {
144+
TypeName: printerInfo.TypeName,
145+
Name: printerInfo.Name,
146+
SerialNumber: printerInfo.SerialNumber,
147+
is5MFamily: familyInfo.is5MFamily
148+
});
149+
150+
this.emit('printer-type-detected', { typeName, familyInfo });
151+
152+
// For legacy printers, we can reuse this connection
153+
if (!familyInfo.is5MFamily) {
154+
console.log('[Connection] Legacy printer detected, reusing connection');
155+
return {
156+
success: true,
157+
typeName,
158+
printerInfo: {
159+
...(printerInfo as unknown as Record<string, unknown>),
160+
_reuseableClient: tempClient // Store for reuse
161+
}
162+
};
163+
} else {
164+
// 5M family - dispose temp client, will create new one
165+
// But first ensure we have critical information for dual API connection
166+
if (!printerInfo.SerialNumber || printerInfo.SerialNumber.trim() === '') {
167+
console.warn('[Connection] Warning: No serial number found in printer info for 5M family printer');
168+
console.warn('[Connection] This may cause dual API connection to fail');
169+
}
170+
171+
console.log('[Connection] 5M family printer detected, disposing temporary connection');
172+
void tempClient.dispose();
173+
174+
// Add a small delay after disposing temp client to ensure clean state
175+
await new Promise(resolve => setTimeout(resolve, 200));
176+
177+
return {
178+
success: true,
179+
typeName,
180+
printerInfo: printerInfo as unknown as ExtendedPrinterInfo
181+
};
182+
}
183+
184+
} catch (error) {
185+
console.error(`[Connection] Attempt ${attempt} failed:`, error);
186+
187+
// Clean up temp client on error
188+
if (tempClient) {
189+
try {
190+
void tempClient.dispose();
191+
} catch (disposeError) {
192+
console.error('[Connection] Error disposing temp client after error:', disposeError);
193+
}
194+
}
195+
196+
if (attempt < retries) {
197+
const backoffDelay = 1000 * attempt;
198+
console.log(`[Connection] Retrying in ${backoffDelay}ms...`);
199+
await this.delay(backoffDelay);
200+
continue;
201+
}
202+
203+
// All retries exhausted
204+
const errorMessage = getConnectionErrorMessage(error);
205+
console.error(`[Connection] All ${retries} attempts failed:`, errorMessage);
206+
this.emit('temporary-connection-failed', errorMessage);
126207
return {
127-
success: true,
128-
typeName,
129-
printerInfo: printerInfo as unknown as ExtendedPrinterInfo
208+
success: false,
209+
error: errorMessage
130210
};
131211
}
132-
133-
} catch (error) {
134-
const errorMessage = getConnectionErrorMessage(error);
135-
this.emit('temporary-connection-failed', errorMessage);
136-
return {
137-
success: false,
138-
error: errorMessage
139-
};
140212
}
213+
214+
// Should never reach here, but TypeScript requires it
215+
return {
216+
success: false,
217+
error: 'All connection attempts failed'
218+
};
219+
}
220+
221+
/**
222+
* Delay helper for exponential backoff
223+
*/
224+
private async delay(ms: number): Promise<void> {
225+
return new Promise(resolve => setTimeout(resolve, ms));
141226
}
142227

143228
/**

src/webui/server/api-routes.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import { registerCameraRoutes } from './routes/camera-routes';
2222
import { registerContextRoutes } from './routes/context-routes';
2323
import { registerThemeRoutes } from './routes/theme-routes';
2424
import { registerSpoolmanRoutes } from './routes/spoolman-routes';
25+
import { registerDiscoveryRoutes } from './routes/discovery-routes';
26+
import { registerPrinterManagementRoutes } from './routes/printer-management-routes';
2527

2628
export function buildRouteDependencies(): RouteDependencies {
2729
return {
@@ -45,6 +47,8 @@ export function createAPIRoutes(deps: RouteDependencies = buildRouteDependencies
4547
registerContextRoutes(router, deps);
4648
registerThemeRoutes(router, deps);
4749
registerSpoolmanRoutes(router, deps);
50+
registerDiscoveryRoutes(router, deps);
51+
registerPrinterManagementRoutes(router, deps);
4852

4953
return router;
5054
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* @fileoverview Discovery API Routes
3+
* Handles network scanning and printer discovery
4+
*/
5+
6+
import type { Router, Response } from 'express';
7+
import type { AuthenticatedRequest } from '../auth-middleware';
8+
import { StandardAPIResponse } from '../../types/web-api.types';
9+
import { toAppError } from '../../../utils/error.utils';
10+
import type { RouteDependencies } from './route-helpers';
11+
import { sendErrorResponse } from './route-helpers';
12+
import { getPrinterDiscoveryService } from '../../../services/PrinterDiscoveryService';
13+
import { getSavedPrinterService } from '../../../services/SavedPrinterService';
14+
15+
export function registerDiscoveryRoutes(router: Router, _deps: RouteDependencies): void {
16+
const discoveryService = getPrinterDiscoveryService();
17+
const savedPrinterService = getSavedPrinterService();
18+
19+
/**
20+
* POST /api/discovery/scan
21+
* Start network-wide printer discovery
22+
*/
23+
router.post('/discovery/scan', async (req: AuthenticatedRequest, res: Response) => {
24+
try {
25+
const body = req.body as {
26+
timeout?: number;
27+
interval?: number;
28+
retries?: number;
29+
};
30+
31+
const timeout = typeof body.timeout === 'number' ? body.timeout : 10000;
32+
const interval = typeof body.interval === 'number' ? body.interval : 2000;
33+
const retries = typeof body.retries === 'number' ? body.retries : 3;
34+
35+
// Validate parameters
36+
if (timeout < 1000 || timeout > 60000) {
37+
return sendErrorResponse(res, 400, 'Timeout must be between 1000 and 60000ms');
38+
}
39+
if (interval < 500 || interval > 5000) {
40+
return sendErrorResponse(res, 400, 'Interval must be between 500 and 5000ms');
41+
}
42+
if (retries < 1 || retries > 5) {
43+
return sendErrorResponse(res, 400, 'Retries must be between 1 and 5');
44+
}
45+
46+
// Check if discovery is already running
47+
if (discoveryService.isDiscoveryInProgress()) {
48+
return sendErrorResponse(res, 409, 'Discovery already in progress');
49+
}
50+
51+
// Start discovery
52+
const discoveredPrinters = await discoveryService.scanNetwork(timeout, interval, retries);
53+
54+
// Match with saved printers
55+
const savedPrinters = savedPrinterService.getSavedPrinters();
56+
const savedMatches = discoveredPrinters.map(discovered => {
57+
const saved = savedPrinters.find(s => s.SerialNumber === discovered.serialNumber);
58+
return {
59+
discovered,
60+
saved: saved || null,
61+
isKnown: !!saved,
62+
ipAddressChanged: saved ? saved.IPAddress !== discovered.ipAddress : false
63+
};
64+
});
65+
66+
return res.json({
67+
success: true,
68+
printers: discoveredPrinters,
69+
savedMatches,
70+
count: discoveredPrinters.length
71+
});
72+
73+
} catch (error) {
74+
console.error('[API] Discovery scan failed:', error);
75+
const appError = toAppError(error);
76+
return sendErrorResponse(res, 500, appError.message);
77+
}
78+
});
79+
80+
/**
81+
* POST /api/discovery/scan-ip
82+
* Scan a specific IP address for a printer
83+
*/
84+
router.post('/discovery/scan-ip', async (req: AuthenticatedRequest, res: Response) => {
85+
try {
86+
const body = req.body as { ipAddress?: string };
87+
const ipAddress = body.ipAddress;
88+
89+
if (!ipAddress || typeof ipAddress !== 'string') {
90+
return sendErrorResponse(res, 400, 'IP address is required');
91+
}
92+
93+
// Basic IP validation
94+
const ipRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
95+
if (!ipRegex.test(ipAddress)) {
96+
return sendErrorResponse(res, 400, 'Invalid IP address format');
97+
}
98+
99+
const printer = await discoveryService.scanSingleIP(ipAddress);
100+
101+
return res.json({
102+
success: true,
103+
printer,
104+
found: printer !== null
105+
});
106+
107+
} catch (error) {
108+
console.error('[API] Single IP scan failed:', error);
109+
const appError = toAppError(error);
110+
return sendErrorResponse(res, 500, appError.message);
111+
}
112+
});
113+
114+
/**
115+
* GET /api/discovery/status
116+
* Get current discovery status
117+
*/
118+
router.get('/discovery/status', (_req: AuthenticatedRequest, res: Response) => {
119+
return res.json({
120+
success: true,
121+
inProgress: discoveryService.isDiscoveryInProgress()
122+
});
123+
});
124+
125+
/**
126+
* POST /api/discovery/cancel
127+
* Cancel ongoing discovery
128+
*/
129+
router.post('/discovery/cancel', (_req: AuthenticatedRequest, res: Response) => {
130+
discoveryService.cancelDiscovery();
131+
return res.json({
132+
success: true,
133+
message: 'Discovery cancelled'
134+
} as StandardAPIResponse);
135+
});
136+
}

0 commit comments

Comments
 (0)