Skip to content

Commit d475e84

Browse files
GhostTypesclaude
andcommitted
feat(creator5): per-tool temps, nozzle count, firmware-format fix
The Creator 5 reports temperatures per-tool and a real nozzle count, but the client was treating it like a single-nozzle printer. All three fixes below are confirmed against both the C5 firmware and the FlashForge slicer's network library (FlashNetwork.dll), so nothing here is a guess. - TempControl can now set individual tool temps on the C5 tool-changer via the nozzles[] array - setToolTemp, setToolTemps and cancelToolTemp. The firmware only accepts the array when it has exactly four entries, so we always send four and use -200 to leave the untouched tools alone. The scalar path (setExtruderTemp) still drives rightNozzle as before. - Expose NozzleCount from detail.nozzleCnt, so the C5 reads as 4 nozzles instead of 1. - isNewFirmwareVersion no longer trips on the C5's 1.9.2 version string; the AD5X and Creator 5 always use the new payload format regardless of their firmware numbering. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8fda689 commit d475e84

7 files changed

Lines changed: 177 additions & 9 deletions

File tree

src/api/controls/JobControl.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,42 @@ describe('JobControl', () => {
175175
);
176176
});
177177

178+
it('should use new firmware format for Creator 5 despite a low version string', async () => {
179+
// The C5 reports a 1.x version that the numeric 3.1.3 check would read as
180+
// "old"; the model flag must override that so material-station fields are sent.
181+
(mockFiveMClient as { isCreator5: boolean }).isCreator5 = true;
182+
mockFiveMClient.firmVer = '1.9.2';
183+
184+
mockedAxios.post.mockResolvedValue({
185+
status: 200,
186+
data: { code: 0, message: 'Success' },
187+
});
188+
189+
const result = await jobControl.printLocalFile('test.gcode', true);
190+
191+
expect(result).toBe(true);
192+
const body = mockedAxios.post.mock.calls[0][1] as Record<string, unknown>;
193+
expect(body).toHaveProperty('useMatlStation', false);
194+
expect(body).toHaveProperty('gcodeToolCnt', 0);
195+
expect(body).toHaveProperty('materialMappings');
196+
});
197+
198+
it('should use new firmware format for AD5X despite a low version string', async () => {
199+
mockFiveMClient.isAD5X = true;
200+
mockFiveMClient.firmVer = '1.1.7';
201+
202+
mockedAxios.post.mockResolvedValue({
203+
status: 200,
204+
data: { code: 0, message: 'Success' },
205+
});
206+
207+
const result = await jobControl.printLocalFile('test.gcode', true);
208+
209+
expect(result).toBe(true);
210+
const body = mockedAxios.post.mock.calls[0][1] as Record<string, unknown>;
211+
expect(body).toHaveProperty('useMatlStation', false);
212+
});
213+
178214
it('should return false for non-200 status', async () => {
179215
mockedAxios.post.mockResolvedValue({
180216
status: 500,

src/api/controls/JobControl.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,14 @@ export class JobControl {
7171
* @private
7272
*/
7373
private isNewFirmwareVersion(): boolean {
74+
// The 3.1.3 threshold only applies to the 5M family. The AD5X and Creator 5
75+
// series always use the new payload/header format, and their firmware
76+
// versioning isn't comparable to the 5M's 3.x line (e.g. the C5 reports
77+
// 1.9.2, which the numeric check below would wrongly read as "old"), so
78+
// short-circuit to the new format for them.
79+
if (this.client.isAD5X || this.client.isCreator5 || this.client.isCreator5Pro) {
80+
return true;
81+
}
7482
try {
7583
const currentVersion = this.client.firmVer.split('.');
7684
const minVersion = [3, 1, 3];

src/api/controls/TempControl.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,5 +224,47 @@ describe('TempControl', () => {
224224

225225
expect(mockTcpClient.gCode).not.toHaveBeenCalled();
226226
});
227+
228+
it('setToolTemp sets one tool and leaves the others at -200', async () => {
229+
const result = await httpTempControl.setToolTemp(2, 230);
230+
231+
expect(result).toBe(true);
232+
expect(mockSendControlCommand).toHaveBeenCalledWith(
233+
'temperatureCtl_cmd',
234+
expect.objectContaining({ nozzles: [-200, -200, 230, -200] })
235+
);
236+
});
237+
238+
it('setToolTemps sends all four per-tool targets', async () => {
239+
await httpTempControl.setToolTemps([200, 210, 220, 230]);
240+
241+
expect(mockSendControlCommand).toHaveBeenCalledWith(
242+
'temperatureCtl_cmd',
243+
expect.objectContaining({ nozzles: [200, 210, 220, 230] })
244+
);
245+
});
246+
247+
it('cancelToolTemp turns a single tool off (-100)', async () => {
248+
await httpTempControl.cancelToolTemp(0);
249+
250+
expect(mockSendControlCommand).toHaveBeenCalledWith(
251+
'temperatureCtl_cmd',
252+
expect.objectContaining({ nozzles: [-100, -200, -200, -200] })
253+
);
254+
});
255+
256+
it('setToolTemp rejects an out-of-range index without sending', async () => {
257+
const result = await httpTempControl.setToolTemp(5, 200);
258+
259+
expect(result).toBe(false);
260+
expect(mockSendControlCommand).not.toHaveBeenCalled();
261+
});
262+
263+
it('setToolTemps rejects a wrong-length array without sending', async () => {
264+
const result = await httpTempControl.setToolTemps([200, 210]);
265+
266+
expect(result).toBe(false);
267+
expect(mockSendControlCommand).not.toHaveBeenCalled();
268+
});
227269
});
228270
});

src/api/controls/TempControl.ts

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ import { Commands } from '../server/Commands';
1616
const TEMP_NO_CHANGE = -200;
1717
/** `temperatureCtl_cmd` value that turns a heater off. */
1818
const TEMP_OFF = -100;
19+
/**
20+
* Number of tool/nozzle entries the Creator 5 firmware requires in the
21+
* `nozzles` array. The firmware ignores the array unless its length is exactly
22+
* this (confirmed via Ghidra: `size() == 4` check in the temp parser).
23+
*/
24+
const NOZZLE_COUNT = 4;
1925

2026
/**
2127
* Provides methods for controlling the temperatures of various components of the FlashForge 3D printer,
@@ -46,13 +52,83 @@ export class TempControl {
4652
leftNozzle?: number;
4753
platform?: number;
4854
chamber?: number;
55+
nozzles?: number[];
4956
}): Promise<boolean> {
50-
return await this.client.control.sendControlCommand(Commands.TempControlCmd, {
57+
const payload: {
58+
rightNozzle: number;
59+
leftNozzle: number;
60+
platform: number;
61+
chamber: number;
62+
nozzles?: number[];
63+
} = {
5164
rightNozzle: args.rightNozzle ?? TEMP_NO_CHANGE,
5265
leftNozzle: args.leftNozzle ?? TEMP_NO_CHANGE,
5366
platform: args.platform ?? TEMP_NO_CHANGE,
5467
chamber: args.chamber ?? TEMP_NO_CHANGE,
55-
});
68+
};
69+
// Per-tool targets for Creator 5 tool-changers. Only included when supplied so
70+
// the firmware's `size() == 4` check doesn't reject a partial/absent array.
71+
if (args.nozzles !== undefined) {
72+
payload.nozzles = args.nozzles;
73+
}
74+
return await this.client.control.sendControlCommand(Commands.TempControlCmd, payload);
75+
}
76+
77+
/**
78+
* Builds a {@link NOZZLE_COUNT}-length `nozzles` array of {@link TEMP_NO_CHANGE}
79+
* placeholders with a single tool set to `value`.
80+
* @returns The array, or null if `toolIndex` is out of range.
81+
*/
82+
private buildNozzleArray(toolIndex: number, value: number): number[] | null {
83+
if (!Number.isInteger(toolIndex) || toolIndex < 0 || toolIndex >= NOZZLE_COUNT) {
84+
console.error(`TempControl: toolIndex ${toolIndex} out of range (0-${NOZZLE_COUNT - 1}).`);
85+
return null;
86+
}
87+
const nozzles = new Array<number>(NOZZLE_COUNT).fill(TEMP_NO_CHANGE);
88+
nozzles[toolIndex] = value;
89+
return nozzles;
90+
}
91+
92+
/**
93+
* Sets the target temperature for a single tool/nozzle on a Creator 5 series
94+
* tool-changer, leaving the other tools unchanged. Sent as a `nozzles` array
95+
* (Creator 5 / 5 Pro, HTTP-only); see {@link setToolTemps} to set all at once.
96+
* @param toolIndex Zero-based tool index (0-3 for T0-T3).
97+
* @param temp Target temperature in Celsius.
98+
* @returns A Promise resolving to true if the command is acknowledged.
99+
*/
100+
public async setToolTemp(toolIndex: number, temp: number): Promise<boolean> {
101+
const nozzles = this.buildNozzleArray(toolIndex, temp);
102+
if (nozzles === null) return false;
103+
return await this.sendHttpTempCommand({ nozzles });
104+
}
105+
106+
/**
107+
* Sets the target temperatures for all tools/nozzles on a Creator 5 series
108+
* tool-changer in one command. Use {@link TEMP_NO_CHANGE} (-200) to leave a
109+
* tool unchanged or {@link TEMP_OFF} (-100) to turn one off. Must contain
110+
* exactly {@link NOZZLE_COUNT} entries.
111+
* @param temps Per-tool target temperatures, ordered T0..T3.
112+
* @returns A Promise resolving to true if the command is acknowledged.
113+
*/
114+
public async setToolTemps(temps: number[]): Promise<boolean> {
115+
if (temps.length !== NOZZLE_COUNT) {
116+
console.error(`setToolTemps: expected ${NOZZLE_COUNT} temps, got ${temps.length}.`);
117+
return false;
118+
}
119+
return await this.sendHttpTempCommand({ nozzles: [...temps] });
120+
}
121+
122+
/**
123+
* Cancels heating for a single tool/nozzle on a Creator 5 series tool-changer
124+
* (sets its target to 0), leaving the other tools unchanged.
125+
* @param toolIndex Zero-based tool index (0-3 for T0-T3).
126+
* @returns A Promise resolving to true if the command is acknowledged.
127+
*/
128+
public async cancelToolTemp(toolIndex: number): Promise<boolean> {
129+
const nozzles = this.buildNozzleArray(toolIndex, TEMP_OFF);
130+
if (nozzles === null) return false;
131+
return await this.sendHttpTempCommand({ nozzles });
56132
}
57133

58134
/**
@@ -112,7 +188,9 @@ export class TempControl {
112188
*/
113189
public async waitForPartCool(temp: number): Promise<void> {
114190
if (this.client.httpOnly) {
115-
console.log('waitForPartCool() unavailable over HTTP-only connection; poll info.get() instead.');
191+
console.log(
192+
'waitForPartCool() unavailable over HTTP-only connection; poll info.get() instead.'
193+
);
116194
return;
117195
}
118196
await this.tcpClient.gCode().waitForBedTemp(temp, true);

src/models/MachineInfo.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,8 @@ describe('MachineInfo', () => {
371371
expect(result.ToolTemps).toHaveLength(4);
372372
expect(result.ToolTemps[0]).toEqual({ current: 25, set: 0 });
373373
expect(result.ToolTemps[3]).toEqual({ current: 28, set: 0 });
374+
// Tool-changer reports its real nozzle count, not 1.
375+
expect(result.NozzleCount).toBe(4);
374376
// Extruder mirrors the first tool's source field (rightTemp absent -> 0).
375377
expect(result.Extruder.current).toBe(0);
376378

@@ -418,6 +420,7 @@ describe('MachineInfo', () => {
418420

419421
expect(result.ToolTemps).toHaveLength(1);
420422
expect(result.ToolTemps[0]).toEqual({ current: 210.3, set: 210 });
423+
expect(result.NozzleCount).toBe(1);
421424
expect(result.HasDoorSensor).toBe(false);
422425
});
423426
});

src/models/MachineInfo.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,7 @@ export class MachineInfo {
7777
isCreator5 = (detail.name || '').includes('Creator 5');
7878
// `model` is the immutable factory name; prefer it over the user-mutable
7979
// `name` when distinguishing the Pro variant without a pid.
80-
isCreator5Pro =
81-
isCreator5 && /Creator 5 Pro/i.test(detail.model || detail.name || '');
80+
isCreator5Pro = isCreator5 && /Creator 5 Pro/i.test(detail.model || detail.name || '');
8281
}
8382

8483
// Per-tool temperatures. Creator 5 series report `nozzleTemps[]` /
@@ -103,10 +102,7 @@ export class MachineInfo {
103102
const hasLidar = detail.lidar === 1;
104103
const hasDoorSensor = isCreator5Pro;
105104
const model =
106-
detail.model ||
107-
(pid !== undefined ? PID_MODEL_NAMES[pid] : undefined) ||
108-
detail.name ||
109-
'';
105+
detail.model || (pid !== undefined ? PID_MODEL_NAMES[pid] : undefined) || detail.name || '';
110106
const printEta = this.formatTimeFromSeconds(detail.estimatedTime || 0);
111107
const completionTime = new Date(Date.now() + (detail.estimatedTime || 0) * 1000);
112108
const formattedRunTime = this.formatTimeFromSeconds(detail.printDuration || 0);
@@ -178,6 +174,9 @@ export class MachineInfo {
178174
IsCreator5Pro: isCreator5Pro,
179175
Model: model,
180176
NozzleSize: detail.nozzleModel || '',
177+
// Prefer the firmware-reported count; fall back to the parsed per-tool
178+
// array length so single-nozzle models still report 1.
179+
NozzleCount: detail.nozzleCnt || toolTemps.length,
181180

182181
// Capabilities (presence-derived)
183182
HasCamera: hasCamera,

src/models/ff-models.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,8 @@ export interface FFMachineInfo {
260260
Model: string;
261261
/** Nozzle size (e.g., "0.4mm"). */
262262
NozzleSize: string;
263+
/** Number of tools/nozzles the printer reports (`detail.nozzleCnt`). Creator 5 series = 4 (tool-changer); single-nozzle models = 1. Mirrors the length of {@link ToolTemps}. */
264+
NozzleCount: number;
263265

264266
/** Whether the printer has a built-in camera (capability flag, not just stream availability). */
265267
HasCamera: boolean;

0 commit comments

Comments
 (0)