Skip to content

Commit 287b204

Browse files
committed
fix(a3): align Adventurer 3 client with documented TCP protocol
1 parent 3e7610d commit 287b204

17 files changed

Lines changed: 1553 additions & 93 deletions

biome.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"useIgnoreFile": true
77
},
88
"files": {
9-
"ignoreUnknown": false
9+
"ignoreUnknown": false,
10+
"includes": ["**", "!.claude/skills"]
1011
},
1112
"formatter": {
1213
"enabled": true,

src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,19 @@ export {
4848
Temperature as TemperatureInterface,
4949
} from './models/ff-models';
5050
export { GCodeController } from './tcpapi/client/GCodeController';
51+
export type { GCodeClientCapabilities } from './tcpapi/client/GCodeClientCapabilities';
5152
export { GCodes } from './tcpapi/client/GCodes';
53+
export { A3GCodeController } from './tcpapi/client/A3GCodeController';
5254
// TCP API
5355
export { FlashForgeClient } from './tcpapi/FlashForgeClient';
5456
export { FlashForgeTcpClient } from './tcpapi/FlashForgeTcpClient';
57+
export {
58+
FlashForgeA3Client,
59+
type A3BuildVolume,
60+
type A3FileEntry,
61+
type A3PrinterInfo,
62+
type A3Thumbnail,
63+
} from './tcpapi/FlashForgeA3Client';
5564
// Replays
5665
export {
5766
Endstop,
Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
/**
2+
* @fileoverview Unit tests for FlashForge Adventurer 3 TCP client.
3+
*/
4+
5+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6+
import { FlashForgeA3Client } from './FlashForgeA3Client';
7+
import { A3GCodeController } from './client/A3GCodeController';
8+
import { PrintStatus } from './replays/PrintStatus';
9+
import { TempInfo } from './replays/TempInfo';
10+
11+
describe('FlashForgeA3Client', () => {
12+
let client: FlashForgeA3Client;
13+
14+
beforeEach(() => {
15+
client = new FlashForgeA3Client('192.168.1.100');
16+
});
17+
18+
afterEach(() => {
19+
vi.restoreAllMocks();
20+
});
21+
22+
describe('Connection', () => {
23+
it('should use port 8899', () => {
24+
expect(client['port']).toBe(8899);
25+
});
26+
27+
it('should initialize connection with M601', async () => {
28+
const sendCommandSpy = vi.spyOn(client, 'sendCommandAsync');
29+
sendCommandSpy.mockResolvedValue('CMD M601 Received.\nok\n');
30+
31+
const result = await client.initControl();
32+
33+
expect(sendCommandSpy).toHaveBeenCalledWith('~M601');
34+
expect(result).toBe(true);
35+
});
36+
37+
it('should handle already connected state gracefully', async () => {
38+
vi.spyOn(client, 'sendCommandAsync').mockResolvedValue('Error: have been connected\n');
39+
40+
await expect(client.initControl()).resolves.toBe(true);
41+
});
42+
});
43+
44+
describe('Printer Information', () => {
45+
it('should parse the documented M115 response format', async () => {
46+
const mockResponse = [
47+
'echo: Machine Type: FlashForge Adventurer III',
48+
'Machine Name: MyPrinter',
49+
'Firmware: v1.3.7',
50+
'Serial Number: SNADVA3M12345',
51+
'X: 150 Y: 150 Z: 150',
52+
'Tool Count: 1',
53+
'Mac Address:00:11:22:33:44:55',
54+
'',
55+
].join('\n');
56+
vi.spyOn(client, 'sendCommandAsync').mockResolvedValue(mockResponse);
57+
58+
const info = await client.getPrinterInfo();
59+
60+
expect(info).not.toBeNull();
61+
expect(info?.machineType).toBe('FlashForge Adventurer III');
62+
expect(info?.machineName).toBe('MyPrinter');
63+
expect(info?.firmware).toBe('v1.3.7');
64+
expect(info?.serialNumber).toBe('SNADVA3M12345');
65+
expect(info?.buildVolume).toEqual({ x: 150, y: 150, z: 150 });
66+
expect(info?.toolCount).toBe(1);
67+
expect(info?.macAddress).toBe('00:11:22:33:44:55');
68+
});
69+
70+
it('should return null on invalid M115 data', async () => {
71+
vi.spyOn(client, 'sendCommandAsync').mockResolvedValue('invalid response');
72+
73+
await expect(client.getPrinterInfo()).resolves.toBeNull();
74+
});
75+
});
76+
77+
describe('Print Job Control', () => {
78+
it('should select a file for printing', async () => {
79+
vi.spyOn(client, 'sendCommandAsync').mockResolvedValue(
80+
'File opened: /data/test.gcode Size: 123456\nDone printing file\nok\n'
81+
);
82+
83+
await expect(client.selectFile('test.gcode')).resolves.toBe(true);
84+
});
85+
86+
it('should start a print job on command echo responses', async () => {
87+
vi.spyOn(client, 'sendCommandAsync').mockResolvedValue('ack: "M24"\n');
88+
89+
await expect(client.startPrint()).resolves.toBe(true);
90+
});
91+
92+
it('should pause a print job on command echo responses', async () => {
93+
vi.spyOn(client, 'sendCommandAsync').mockResolvedValue('ack: "M25"\n');
94+
95+
await expect(client.pausePrint()).resolves.toBe(true);
96+
});
97+
98+
it('should stop a print job on received responses', async () => {
99+
vi.spyOn(client, 'sendCommandAsync').mockResolvedValue('CMD M26 Received.\n');
100+
101+
await expect(client.stopPrint()).resolves.toBe(true);
102+
});
103+
104+
it('should parse M27 progress without legacy layer metadata', async () => {
105+
vi.spyOn(client, 'sendCommandAsync').mockResolvedValue('ack: "SD printing byte 45/100\r\n"');
106+
107+
const status = await client.getPrintStatus();
108+
109+
expect(status).toBeInstanceOf(PrintStatus);
110+
expect(status?.getSdProgress()).toBe('45/100');
111+
expect(status?.getPrintPercent()).toBe(45);
112+
});
113+
});
114+
115+
describe('Temperature Control', () => {
116+
it('should parse single-line M105 responses', async () => {
117+
vi.spyOn(client, 'sendCommandAsync').mockResolvedValue('ok T0:185/200 B:60/60\r\n');
118+
119+
const tempInfo = await client.getTempInfo();
120+
121+
expect(tempInfo).toBeInstanceOf(TempInfo);
122+
expect(tempInfo?.getExtruderTemp()?.getCurrent()).toBe(185);
123+
expect(tempInfo?.getExtruderTemp()?.getSet()).toBe(200);
124+
expect(tempInfo?.getBedTemp()?.getCurrent()).toBe(60);
125+
expect(tempInfo?.getBedTemp()?.getSet()).toBe(60);
126+
});
127+
});
128+
129+
describe('Motion Control', () => {
130+
it('should enable motors with M17', async () => {
131+
vi.spyOn(client, 'sendCommandAsync').mockResolvedValue('CMD M17 Received.\n');
132+
133+
await expect(client.enableMotors()).resolves.toBe(true);
134+
});
135+
136+
it('should disable motors with M18', async () => {
137+
vi.spyOn(client, 'sendCommandAsync').mockResolvedValue('CMD M18 Received.\n');
138+
139+
await expect(client.disableMotors()).resolves.toBe(true);
140+
});
141+
142+
it('should treat homing as a fire-and-forget success when the write succeeds', async () => {
143+
vi.spyOn(client, 'sendCommandAsync').mockResolvedValue('');
144+
145+
await expect(client.home()).resolves.toBe(true);
146+
});
147+
148+
it('should treat manual moves as fire-and-forget success when the write succeeds', async () => {
149+
vi.spyOn(client, 'sendCommandAsync').mockResolvedValue('');
150+
151+
await expect(client.move(100, 100, 10, 3000)).resolves.toBe(true);
152+
});
153+
});
154+
155+
describe('G-code Controller Integration', () => {
156+
it('should map inherited LED methods to Adventurer 3 M146 semantics', async () => {
157+
const controller = new A3GCodeController(client);
158+
const sendCommandSpy = vi.spyOn(client, 'sendCommandAsync');
159+
sendCommandSpy
160+
.mockResolvedValueOnce('ack: "M146 1"\n')
161+
.mockResolvedValueOnce('ack: "M146 0"\n');
162+
163+
await expect(controller.ledOn()).resolves.toBe(true);
164+
await expect(controller.ledOff()).resolves.toBe(true);
165+
166+
expect(sendCommandSpy).toHaveBeenNthCalledWith(1, '~M146 1');
167+
expect(sendCommandSpy).toHaveBeenNthCalledWith(2, '~M146 0');
168+
});
169+
170+
it('should select then start a job using the Adventurer 3 M23/M24 flow', async () => {
171+
const controller = new A3GCodeController(client);
172+
const sendCommandSpy = vi.spyOn(client, 'sendCommandAsync');
173+
sendCommandSpy
174+
.mockResolvedValueOnce('File opened: /data/test.gcode Size: 123456\nDone printing file\nok\n')
175+
.mockResolvedValueOnce('ack: "M24"\n');
176+
177+
await expect(controller.startJob('test.gcode')).resolves.toBe(true);
178+
179+
expect(sendCommandSpy).toHaveBeenNthCalledWith(1, '~M23 /data/test.gcode');
180+
expect(sendCommandSpy).toHaveBeenNthCalledWith(2, '~M24');
181+
});
182+
183+
it('should get the printer model from M115 rather than M650', async () => {
184+
const controller = new A3GCodeController(client);
185+
const sendCommandSpy = vi.spyOn(client, 'sendCommandAsync').mockResolvedValue([
186+
'echo: Machine Type: FlashForge Adventurer III',
187+
'Machine Name: MyPrinter',
188+
'Firmware: v1.3.7',
189+
'Serial Number: SNADVA3M12345',
190+
'X: 150 Y: 150 Z: 150',
191+
'Tool Count: 1',
192+
'Mac Address:00:11:22:33:44:55',
193+
'',
194+
].join('\n'));
195+
196+
await expect(controller.getPrinterModel()).resolves.toBe('FlashForge Adventurer III');
197+
expect(sendCommandSpy).toHaveBeenCalledWith('~M115');
198+
});
199+
});
200+
201+
describe('File Operations', () => {
202+
it('should list files from the documented M661 response format', async () => {
203+
const mockResponse = [
204+
'CMD M661 Received.',
205+
'info_list.size: 3',
206+
'test1.gcode',
207+
'test2.gx',
208+
'test3.g',
209+
].join('\n');
210+
vi.spyOn(client, 'sendCommandAsync').mockResolvedValue(mockResponse);
211+
212+
const files = await client.listFiles();
213+
214+
expect(files).toHaveLength(3);
215+
expect(files[0].name).toBe('test1.gcode');
216+
expect(files[0].path).toBe('/data/test1.gcode');
217+
});
218+
219+
it('should return an empty file list on documented M661 error responses', async () => {
220+
vi.spyOn(client, 'sendCommandAsync').mockResolvedValue('CMD M661 Error.\n');
221+
222+
await expect(client.listFiles()).resolves.toEqual([]);
223+
});
224+
225+
it('should parse thumbnails after the documented M662 text header', async () => {
226+
const thumbnailPayload = Buffer.alloc(100);
227+
thumbnailPayload.writeUInt32BE(0xa2a22a2a, 0);
228+
thumbnailPayload.writeUInt32BE(92, 4);
229+
230+
const fullResponse = Buffer.concat([
231+
Buffer.from('CMD M662 Received.\nack header length: 64\n', 'utf8'),
232+
thumbnailPayload,
233+
]);
234+
vi.spyOn(client, 'sendCommandAsync').mockResolvedValue(fullResponse.toString('binary'));
235+
236+
const thumbnail = await client.getThumbnail('test.gcode');
237+
238+
expect(thumbnail).not.toBeNull();
239+
expect(thumbnail?.data).toBeInstanceOf(Buffer);
240+
expect(thumbnail?.data.length).toBe(92);
241+
});
242+
243+
it('should return null for documented M662 file-not-found errors', async () => {
244+
vi.spyOn(client, 'sendCommandAsync').mockResolvedValue(
245+
'CMD M662 Received.\nError: File not exists\n'
246+
);
247+
248+
await expect(client.getThumbnail('missing.gcode')).resolves.toBeNull();
249+
});
250+
});
251+
252+
describe('Position and Status', () => {
253+
it('should parse variable M114 position formats defensively', async () => {
254+
vi.spyOn(client, 'sendCommandAsync').mockResolvedValue(
255+
'CMD M114 Received.\nX:100.5 Y:120.3 Z:0.3 A:5.2 B:0.0\n'
256+
);
257+
258+
const position = await client.getPosition();
259+
260+
expect(position).not.toBeNull();
261+
expect(position?.X).toBe('100.5');
262+
expect(position?.Y).toBe('120.3');
263+
expect(position?.Z).toBe('0.3');
264+
});
265+
266+
it('should return null when M663 only acknowledges the command', async () => {
267+
vi.spyOn(client, 'sendCommandAsync').mockResolvedValue('CMD M663 Received.\nok\n');
268+
269+
await expect(client.getPositionXYZE()).resolves.toBeNull();
270+
});
271+
272+
it('should parse the documented Adventurer 3 M119 status format', async () => {
273+
const mockResponse = [
274+
'echo: Endstop: X-max: 0 Y-max: 0 Z-min: 1',
275+
'MachineStatus: IDLE',
276+
'MoveMode: 0.0',
277+
'FilamentStatus: ok',
278+
'LEDStatus: on',
279+
'PrintFileName: test.gcode',
280+
].join('\n');
281+
vi.spyOn(client, 'sendCommandAsync').mockResolvedValue(mockResponse);
282+
283+
const endstop = await client.getEndstopStatus();
284+
285+
expect(endstop).not.toBeNull();
286+
expect(endstop?.isReady()).toBe(true);
287+
expect(endstop?._Endstop?.Zmin).toBe(1);
288+
expect(endstop?._FilamentStatus).toBe('ok');
289+
expect(endstop?._LedEnabled).toBe(true);
290+
expect(endstop?._CurrentFile).toBe('test.gcode');
291+
});
292+
});
293+
294+
describe('Emergency Operations', () => {
295+
it('should treat emergency stop acknowledgements as success', async () => {
296+
vi.spyOn(client, 'sendCommandAsync').mockResolvedValue('echo: Emergency Stop!!!\n');
297+
298+
await expect(client.emergencyStop()).resolves.toBe(true);
299+
});
300+
301+
it('should acknowledge the documented no-op M108 handler', async () => {
302+
vi.spyOn(client, 'sendCommandAsync').mockResolvedValue('CMD M108 Received.\n');
303+
304+
await expect(client.cancelHeatWait()).resolves.toBe(true);
305+
});
306+
});
307+
308+
describe('LED Control', () => {
309+
it('should send the documented on/off style M146 command', async () => {
310+
const sendCommandSpy = vi
311+
.spyOn(client, 'sendCommandAsync')
312+
.mockResolvedValue('ack: "M146 1"\n');
313+
314+
await expect(client.ledControl('1')).resolves.toBe(true);
315+
expect(sendCommandSpy).toHaveBeenCalledWith('~M146 1');
316+
});
317+
});
318+
319+
describe('Custom Commands', () => {
320+
it('should send M144 command', async () => {
321+
vi.spyOn(client, 'sendCommandAsync').mockResolvedValue('ack: "M144"\n');
322+
323+
await expect(client.customM144()).resolves.toContain('M144');
324+
});
325+
326+
it('should send M145 command', async () => {
327+
vi.spyOn(client, 'sendCommandAsync').mockResolvedValue('ack: "M145"\n');
328+
329+
await expect(client.customM145()).resolves.toContain('M145');
330+
});
331+
332+
it('should return M650 calibration values rather than a model name', async () => {
333+
const mockResponse = 'echo: "CMD M650 Received.\nX: 1.0 Y: 0.5\n"';
334+
vi.spyOn(client, 'sendCommandAsync').mockResolvedValue(mockResponse);
335+
336+
await expect(client.customM650()).resolves.toContain('X: 1.0 Y: 0.5');
337+
});
338+
339+
it('should send M610 to set printer name', async () => {
340+
vi.spyOn(client, 'sendCommandAsync').mockResolvedValue('CMD M610 Received.\nok\n');
341+
342+
await expect(client.setPrinterName('My Printer')).resolves.toBe(true);
343+
});
344+
});
345+
346+
describe('Error Handling', () => {
347+
it('should return false when a command fails', async () => {
348+
vi.spyOn(client, 'sendCommandAsync').mockResolvedValue(null);
349+
350+
await expect(client.startPrint()).resolves.toBe(false);
351+
});
352+
353+
it('should return false on explicit printer errors', async () => {
354+
vi.spyOn(client, 'sendCommandAsync').mockResolvedValue('Error: failed\n');
355+
356+
await expect(client.startPrint()).resolves.toBe(false);
357+
});
358+
});
359+
});

0 commit comments

Comments
 (0)