Skip to content

Commit 7a494fd

Browse files
committed
feat(legacy): add PID fallback discovery and shared file upload
1 parent 287b204 commit 7a494fd

5 files changed

Lines changed: 410 additions & 39 deletions

File tree

src/api/PrinterDiscovery.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,40 @@ describe('PrinterDiscovery', () => {
286286
expect(result?.status).toBe(PrinterStatus.Error);
287287
});
288288

289+
it('should classify an unknown legacy printer by Adventurer 4 PID fallback', () => {
290+
const discovery = createDiscovery();
291+
const buffer = createLegacyBuffer({
292+
name: 'Workshop Printer',
293+
productId: 0x001e,
294+
});
295+
296+
const result = discovery['parseLegacyProtocol'](buffer, {
297+
address: '10.0.0.101',
298+
port: 8899,
299+
family: 'IPv4',
300+
size: 140,
301+
});
302+
303+
expect(result?.model).toBe(PrinterModel.Adventurer4);
304+
});
305+
306+
it('should classify an unknown legacy printer by Adventurer 3 PID fallback', () => {
307+
const discovery = createDiscovery();
308+
const buffer = createLegacyBuffer({
309+
name: 'Workshop Printer',
310+
productId: 0x0008,
311+
});
312+
313+
const result = discovery['parseLegacyProtocol'](buffer, {
314+
address: '10.0.0.102',
315+
port: 8899,
316+
family: 'IPv4',
317+
size: 140,
318+
});
319+
320+
expect(result?.model).toBe(PrinterModel.Adventurer3);
321+
});
322+
289323
it('should throw InvalidResponseError for undersized buffer', () => {
290324
const discovery = createDiscovery();
291325
const buffer = Buffer.alloc(50); // Too small
@@ -369,6 +403,30 @@ describe('PrinterDiscovery', () => {
369403
expect(discovery['detectLegacyModel']('AD3')).toBe(PrinterModel.Adventurer3);
370404
});
371405

406+
it('should use Adventurer 4 PID as a fallback when the name is unknown', () => {
407+
const discovery = createDiscovery();
408+
expect(discovery['detectLegacyModel']('Workshop Printer', 0x001e)).toBe(
409+
PrinterModel.Adventurer4
410+
);
411+
});
412+
413+
it('should use Adventurer 3 PID as a fallback when the name is unknown', () => {
414+
const discovery = createDiscovery();
415+
expect(discovery['detectLegacyModel']('Workshop Printer', 0x0008)).toBe(
416+
PrinterModel.Adventurer3
417+
);
418+
});
419+
420+
it('should prefer name matches over conflicting PID hints', () => {
421+
const discovery = createDiscovery();
422+
expect(discovery['detectLegacyModel']('Adventurer 3', 0x001e)).toBe(
423+
PrinterModel.Adventurer3
424+
);
425+
expect(discovery['detectLegacyModel']('Adventurer 4', 0x0008)).toBe(
426+
PrinterModel.Adventurer4
427+
);
428+
});
429+
372430
it('should return Unknown for unrecognized models', () => {
373431
const discovery = createDiscovery();
374432
expect(discovery['detectLegacyModel']('Unknown Printer')).toBe(PrinterModel.Unknown);

src/api/PrinterDiscovery.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,16 @@ const MODERN_PROTOCOL_SIZE = 276;
4444
*/
4545
const LEGACY_PROTOCOL_SIZE = 140;
4646

47+
/**
48+
* Known legacy product IDs observed in firmware/discovery research.
49+
*
50+
* These are used as hints only when the printer name does not identify the model.
51+
*/
52+
const LEGACY_PRODUCT_IDS = {
53+
Adventurer3: 0x0008,
54+
Adventurer4: 0x001e,
55+
} as const;
56+
4757
/**
4858
* EventEmitter-based continuous discovery monitor.
4959
*
@@ -564,7 +574,7 @@ export class PrinterDiscovery {
564574
const status = this.mapStatusCode(statusCode);
565575

566576
// Detect model
567-
const model = this.detectLegacyModel(name);
577+
const model = this.detectLegacyModel(name, productId);
568578

569579
return {
570580
model,
@@ -619,13 +629,14 @@ export class PrinterDiscovery {
619629
/**
620630
* Detects printer model from legacy protocol response.
621631
*
622-
* Uses printer name heuristics for legacy models.
632+
* Uses printer name heuristics first, then known PID hints as a fallback.
623633
*
624634
* @param name Printer name from response
635+
* @param productId Legacy product ID when available
625636
* @returns Detected printer model
626637
* @private
627638
*/
628-
protected detectLegacyModel(name: string): PrinterModel {
639+
protected detectLegacyModel(name: string, productId?: number): PrinterModel {
629640
const upperName = name.toUpperCase();
630641

631642
if (upperName.includes('ADVENTURER 4') || upperName.includes('ADVENTURER4') || upperName.includes('AD4')) {
@@ -636,6 +647,14 @@ export class PrinterDiscovery {
636647
return PrinterModel.Adventurer3;
637648
}
638649

650+
if (productId === LEGACY_PRODUCT_IDS.Adventurer4) {
651+
return PrinterModel.Adventurer4;
652+
}
653+
654+
if (productId === LEGACY_PRODUCT_IDS.Adventurer3) {
655+
return PrinterModel.Adventurer3;
656+
}
657+
639658
return PrinterModel.Unknown;
640659
}
641660

src/tcpapi/FlashForgeTcpClient.test.ts

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
/**
22
* @fileoverview Tests for FlashForgeTcpClient file list parsing logic, validating extraction
3-
* of filenames from M661 command responses across different printer models.
3+
* of filenames from M661 command responses across different printer models and
4+
* the shared legacy upload workflow used by Adventurer 3/4-style printers.
45
*/
6+
import * as fs from 'node:fs';
7+
import type * as net from 'node:net';
8+
import * as os from 'node:os';
9+
import * as path from 'node:path';
10+
import { EventEmitter } from 'node:events';
511
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
12+
import { GCodes } from './client/GCodes';
613
import { FlashForgeTcpClient } from './FlashForgeTcpClient';
714

815
// Suppress logs (from API files) during tests
@@ -34,6 +41,61 @@ const parseFileListResponse = (response: string): string[] => {
3441
return result;
3542
};
3643

44+
const createUploadTestClient = (
45+
commandResponses: Record<string, string | null>
46+
): {
47+
client: FlashForgeTcpClient;
48+
writes: Array<string | Buffer>;
49+
} => {
50+
const writes: Array<string | Buffer> = [];
51+
const mockSocket = new EventEmitter() as net.Socket;
52+
53+
Object.assign(mockSocket, {
54+
destroyed: false,
55+
setTimeout: vi.fn(),
56+
destroy: vi.fn(() => {
57+
Object.assign(mockSocket, { destroyed: true });
58+
}),
59+
write: vi.fn(
60+
(
61+
data: string | Buffer,
62+
encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),
63+
callbackMaybe?: (error?: Error | null) => void
64+
) => {
65+
const callback =
66+
typeof encodingOrCallback === 'function' ? encodingOrCallback : callbackMaybe;
67+
writes.push(Buffer.isBuffer(data) ? Buffer.from(data) : data);
68+
callback?.(null);
69+
70+
if (!Buffer.isBuffer(data)) {
71+
const response = commandResponses[data];
72+
if (response !== undefined && response !== null) {
73+
setTimeout(() => {
74+
mockSocket.emit('data', Buffer.from(response, 'utf8'));
75+
}, 0);
76+
}
77+
}
78+
79+
return true;
80+
}
81+
),
82+
});
83+
84+
// @ts-expect-error access private connect for tests
85+
const originalConnect = FlashForgeTcpClient.prototype.connect;
86+
// @ts-expect-error inject a mocked socket instead of opening a real TCP connection
87+
FlashForgeTcpClient.prototype.connect = function mockConnect(): void {
88+
this.socket = mockSocket;
89+
};
90+
91+
const client = new FlashForgeTcpClient('localhost');
92+
93+
// @ts-expect-error restore private method after client creation
94+
FlashForgeTcpClient.prototype.connect = originalConnect;
95+
96+
return { client, writes };
97+
};
98+
3799
describe('FlashForgeTcpClient', () => {
38100
// Mock socket methods
39101
beforeAll(() => {
@@ -126,4 +188,71 @@ describe('FlashForgeTcpClient', () => {
126188
expect(result.length).toBe(15);
127189
});
128190
});
191+
192+
describe('uploadFile', () => {
193+
it('should upload a legacy file using M28, raw binary data, and M29', async () => {
194+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ff-api-upload-'));
195+
const localFile = path.join(tempDir, 'test-upload.gcode');
196+
const fileData = Buffer.from('G28\nG1 X10 Y10\nM104 S200\n', 'utf8');
197+
fs.writeFileSync(localFile, fileData);
198+
199+
const startCommand = `${GCodes.CmdPrepFileUpload.replace('%%size%%', fileData.length.toString()).replace('%%filename%%', 'test-upload.gcode')}\n`;
200+
const { client, writes } = createUploadTestClient({
201+
[startCommand]: 'CMD M28 Received.\n/data/test-upload.gcode\n',
202+
[`${GCodes.CmdCompleteFileUpload}\n`]: 'CMD M29 Received.\n',
203+
});
204+
205+
try {
206+
await expect(client.uploadFile(localFile)).resolves.toBe(true);
207+
} finally {
208+
fs.rmSync(tempDir, { recursive: true, force: true });
209+
}
210+
211+
const textWrites = writes.filter((entry): entry is string => typeof entry === 'string');
212+
const binaryWrites = writes.filter((entry): entry is Buffer => Buffer.isBuffer(entry));
213+
214+
expect(textWrites).toEqual([startCommand, `${GCodes.CmdCompleteFileUpload}\n`]);
215+
expect(Buffer.concat(binaryWrites)).toEqual(fileData);
216+
});
217+
218+
it('should normalize legacy prefixes in the requested remote file name', async () => {
219+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ff-api-upload-'));
220+
const localFile = path.join(tempDir, 'local-name.gcode');
221+
const fileData = Buffer.from('M105\n', 'utf8');
222+
fs.writeFileSync(localFile, fileData);
223+
224+
const normalizedStartCommand = `${GCodes.CmdPrepFileUpload.replace('%%size%%', fileData.length.toString()).replace('%%filename%%', 'renamed.gx')}\n`;
225+
const { client, writes } = createUploadTestClient({
226+
[normalizedStartCommand]: 'CMD M28 Received.\n/data/renamed.gx\n',
227+
[`${GCodes.CmdCompleteFileUpload}\n`]: 'CMD M29 Received.\n',
228+
});
229+
230+
try {
231+
await expect(client.uploadFile(localFile, '/data/renamed.gx')).resolves.toBe(true);
232+
} finally {
233+
fs.rmSync(tempDir, { recursive: true, force: true });
234+
}
235+
236+
expect(writes[0]).toBe(normalizedStartCommand);
237+
});
238+
239+
it('should fail the upload when M29 reports a size mismatch', async () => {
240+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ff-api-upload-'));
241+
const localFile = path.join(tempDir, 'broken-upload.gcode');
242+
const fileData = Buffer.from('G1 X5 Y5\n', 'utf8');
243+
fs.writeFileSync(localFile, fileData);
244+
245+
const startCommand = `${GCodes.CmdPrepFileUpload.replace('%%size%%', fileData.length.toString()).replace('%%filename%%', 'broken-upload.gcode')}\n`;
246+
const { client } = createUploadTestClient({
247+
[startCommand]: 'CMD M28 Received.\n/data/broken-upload.gcode\n',
248+
[`${GCodes.CmdCompleteFileUpload}\n`]: 'CMD M29 Received.\nFile Is Not Available\n',
249+
});
250+
251+
try {
252+
await expect(client.uploadFile(localFile)).resolves.toBe(false);
253+
} finally {
254+
fs.rmSync(tempDir, { recursive: true, force: true });
255+
}
256+
});
257+
});
129258
});

0 commit comments

Comments
 (0)