Skip to content

Commit 00c8066

Browse files
GhostTypesclaude
andcommitted
feat(creator5): correct material-mapping, upload, and temp wire formats
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 19df443 commit 00c8066

6 files changed

Lines changed: 254 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.6.0] - 2026-06-26
11+
12+
### Added
13+
14+
- **`JobControl.uploadFileCreator5(params)`** — Creator 5 / Creator 5 Pro file upload (`POST /uploadGcode`) with the C5-specific material-station headers. Unlike the AD5X, the C5 splits the multi-material workflow across two requests: the upload carries `useMatlStation` / `gcodeToolCnt` (the firmware reads them here to register a multi-tool job), and the per-tool mapping is sent separately at print-start via `startCreator5Job` (`POST /printGcode`). This path sends **no** `firstLayerInspection` header (the field doesn't exist on the C5) and **no** `materialMappings` header (mappings go on the print-start call), and the booleans are sent as the string `"true"`/`"false"` (the firmware checks for `"true"`, not `"1"`). `Creator5UploadParams` type exported.
15+
16+
### Changed
17+
18+
- **`Creator5MaterialMapping` now carries `toolMaterialColor` / `slotMaterialColor`.** A live C5 `/printGcode` capture confirmed the mapping shape is identical to the AD5X (it includes the tool/slot colors), not the 3-field `{ toolId, slotId, materialName }` we previously assumed. `startCreator5Job` validates the colors (`#RRGGBB`) like the AD5X path. **Breaking** for callers constructing `Creator5MaterialMapping` by hand — add the two color fields.
19+
- **`startCreator5Job` `/printGcode` body now always includes `flowCalibration` and `timeLapseVideo`** (default `false`), matching the exact body the FlashForge app sends. It still omits `useMatlStation` / `gcodeToolCnt` (those live on the upload) and `firstLayerInspection` (absent on the C5).
20+
- **Creator 5 `temperatureCtl_cmd` now always carries the 4-entry `nozzles` array**, even on a bed- or chamber-only command (unspecified tools default to `-200`/no-change). The confirmed C5 payload always includes the array; previously bed/chamber-only commands omitted it, which the firmware's `size() == 4` check could reject.
21+
1022
## [1.4.0] - 2026-06-25
1123

1224
### Added

src/api/controls/JobControl.test.ts

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -518,35 +518,64 @@ describe('JobControl', () => {
518518
(mockFiveMClient as { isCreator5: boolean }).isCreator5 = true;
519519
});
520520

521-
it('POSTs the Creator 5-native /printGcode body with 3-field mappings', async () => {
521+
it('POSTs the Creator 5-native /printGcode body matching the captured shape', async () => {
522522
mockedAxios.post.mockResolvedValue({ status: 200, data: { code: 0, message: 'Success' } });
523523

524524
const result = await jobControl.startCreator5Job({
525525
fileName: 'multi.3mf',
526526
levelingBeforePrint: true,
527527
materialMappings: [
528-
{ toolId: 0, slotId: 1, materialName: 'PLA' },
529-
{ toolId: 1, slotId: 3, materialName: 'PETG' },
528+
{
529+
toolId: 0,
530+
slotId: 2,
531+
materialName: 'PLA',
532+
toolMaterialColor: '#2E54DD',
533+
slotMaterialColor: '#2E54DD',
534+
},
535+
{
536+
toolId: 1,
537+
slotId: 3,
538+
materialName: 'PETG',
539+
toolMaterialColor: '#FF0000',
540+
slotMaterialColor: '#FF0000',
541+
},
530542
],
531543
});
532544

533545
expect(result).toBe(true);
534546
expect(mockedAxios.post).toHaveBeenCalledTimes(1);
535547
const [url, body] = mockedAxios.post.mock.calls[0];
536548
expect(url).toBe(`http://printer:8898${Endpoints.GCodePrint}`);
537-
// Only Creator 5 fields — no AD5X-only useMatlStation/gcodeToolCnt/colors.
549+
// Confirmed C5 capture: flowCalibration/timeLapseVideo always present, mappings
550+
// carry colors (same shape as AD5X), and no useMatlStation/gcodeToolCnt (those
551+
// live on the upload) or firstLayerInspection (doesn't exist on the C5).
538552
expect(body).toEqual({
539553
serialNumber: 'SN123456',
540554
checkCode: 'CC123456',
541555
fileName: 'multi.3mf',
542556
levelingBeforePrint: true,
557+
flowCalibration: false,
558+
timeLapseVideo: false,
543559
materialMappings: [
544-
{ toolId: 0, slotId: 1, materialName: 'PLA' },
545-
{ toolId: 1, slotId: 3, materialName: 'PETG' },
560+
{
561+
toolId: 0,
562+
slotId: 2,
563+
materialName: 'PLA',
564+
toolMaterialColor: '#2E54DD',
565+
slotMaterialColor: '#2E54DD',
566+
},
567+
{
568+
toolId: 1,
569+
slotId: 3,
570+
materialName: 'PETG',
571+
toolMaterialColor: '#FF0000',
572+
slotMaterialColor: '#FF0000',
573+
},
546574
],
547575
});
548576
expect(body).not.toHaveProperty('useMatlStation');
549577
expect(body).not.toHaveProperty('gcodeToolCnt');
578+
expect(body).not.toHaveProperty('firstLayerInspection');
550579
});
551580

552581
it('omits materialMappings for a single-tool print', async () => {
@@ -566,7 +595,15 @@ describe('JobControl', () => {
566595
const result = await jobControl.startCreator5Job({
567596
fileName: 'bad.3mf',
568597
levelingBeforePrint: true,
569-
materialMappings: [{ toolId: 0, slotId: 0, materialName: 'PLA' }],
598+
materialMappings: [
599+
{
600+
toolId: 0,
601+
slotId: 0,
602+
materialName: 'PLA',
603+
toolMaterialColor: '#2E54DD',
604+
slotMaterialColor: '#2E54DD',
605+
},
606+
],
570607
});
571608

572609
expect(result).toBe(false);

src/api/controls/JobControl.ts

Lines changed: 130 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
AD5XUploadParams,
1717
Creator5JobParams,
1818
Creator5MaterialMapping,
19+
Creator5UploadParams,
1920
} from '../../models/ff-models';
2021
import { NetworkUtils } from '../network/NetworkUtils';
2122
import { Endpoints } from '../server/Endpoints';
@@ -509,21 +510,113 @@ export class JobControl {
509510
}
510511

511512
// --- Creator 5 / Creator 5 Pro ---
512-
// The Creator 5 uploads files the same way as the AD5X (the extra IFS headers
513-
// are simply ignored by its firmware), but it performs material matching at
514-
// print-start via POST /printGcode rather than at upload time. So upload reuses
515-
// the AD5X path, while starting a job uses the Creator 5-native body below.
513+
// The Creator 5 splits the material-station workflow across two requests, unlike
514+
// the AD5X (which maps materials at upload time). On the C5:
515+
// 1. Upload the file (POST /uploadGcode). The firmware reads `useMatlStation`
516+
// and `gcodeToolCnt` here to register the file as a multi-tool job. There is
517+
// NO `firstLayerInspection` header (the field doesn't exist on the C5), and
518+
// the booleans are checked as the string "true"/"false" (not "1"/"0").
519+
// 2. Start the print (POST /printGcode) with the per-tool `materialMappings`.
520+
// See {@link startCreator5Job} for step 2.
516521

517522
/**
518-
* Uploads a file to a Creator 5 / Creator 5 Pro. Reuses the AD5X upload path;
519-
* the Creator 5 firmware ignores the IFS-specific headers. To run a multi-tool
520-
* print, upload with `startPrint = false`, then call {@link startCreator5Job}
521-
* with material mappings.
522-
* @param params Upload parameters (material mappings are ignored by the C5 firmware).
523+
* Uploads a file (.gcode or .3mf) to a Creator 5 / Creator 5 Pro via
524+
* `POST /uploadGcode`, with the C5-specific material-station headers.
525+
*
526+
* For a multi-tool print, set `useMatlStation = true` and `gcodeToolCnt` to the
527+
* tool count, upload with `startPrint = false`, then call {@link startCreator5Job}
528+
* with the material mappings. For a single-tool print, `useMatlStation = false`
529+
* and `gcodeToolCnt = 1`; `startPrint = true` will auto-start it.
530+
*
531+
* Unlike {@link uploadFileAD5X} this sends no `firstLayerInspection` header (the
532+
* C5 has no such field) and no `materialMappings` header (the C5 maps materials at
533+
* print-start, not upload).
534+
* @param params Creator 5 upload parameters.
523535
* @returns Promise resolving to true on success.
524536
*/
525-
public async uploadFileWithMaterialMappings(params: AD5XUploadParams): Promise<boolean> {
526-
return this.uploadFileAD5X(params);
537+
public async uploadFileCreator5(params: Creator5UploadParams): Promise<boolean> {
538+
if (!fs.existsSync(params.filePath)) {
539+
console.error(`uploadFileCreator5 error: File not found at ${params.filePath}`);
540+
return false;
541+
}
542+
543+
const stats = fs.statSync(params.filePath);
544+
const fileSize = stats.size;
545+
const fileName = path.basename(params.filePath);
546+
547+
console.log(
548+
`Starting Creator 5 upload for ${fileName}, Size: ${fileSize}, Start: ${params.startPrint}, ` +
549+
`Level: ${params.levelingBeforePrint}, MatlStation: ${params.useMatlStation}, Tools: ${params.gcodeToolCnt}`
550+
);
551+
552+
try {
553+
const form = new FormData();
554+
form.append('gcodeFile', fs.createReadStream(params.filePath), {
555+
filename: fileName,
556+
contentType: 'application/octet-stream',
557+
});
558+
559+
// C5 upload headers. No firstLayerInspection (absent on the C5); booleans are
560+
// sent as the string "true"/"false" (the firmware checks for "true", not "1").
561+
const customHeaders: Record<string, string> = {
562+
serialNumber: this.client.serialNumber,
563+
checkCode: this.client.checkCode,
564+
fileSize: fileSize.toString(),
565+
printNow: params.startPrint.toString().toLowerCase(),
566+
levelingBeforePrint: params.levelingBeforePrint.toString().toLowerCase(),
567+
flowCalibration: (params.flowCalibration ?? false).toString().toLowerCase(),
568+
timeLapseVideo: (params.timeLapseVideo ?? false).toString().toLowerCase(),
569+
useMatlStation: params.useMatlStation.toString().toLowerCase(),
570+
gcodeToolCnt: params.gcodeToolCnt.toString(),
571+
Expect: '100-continue',
572+
};
573+
574+
const formHeaders = form.getHeaders();
575+
const requestHeaders = {
576+
...customHeaders,
577+
'Content-Type': formHeaders['content-type'],
578+
};
579+
580+
console.log('Creator 5 Upload Request Headers:', requestHeaders);
581+
582+
const response = await axios.post(
583+
this.client.getEndpoint(Endpoints.UploadFile),
584+
form,
585+
{ headers: requestHeaders }
586+
);
587+
588+
console.log(`Creator 5 Upload Response Status: ${response.status}`);
589+
console.log('Creator 5 Upload Response Data:', response.data);
590+
591+
if (response.status !== 200) {
592+
console.error(`Creator 5 Upload failed: Printer responded with status ${response.status}`);
593+
return false;
594+
}
595+
596+
const result = response.data as GenericResponse;
597+
if (NetworkUtils.isOk(result)) {
598+
console.log('Creator 5 Upload successful according to printer response.');
599+
return true;
600+
}
601+
console.error(
602+
`Creator 5 Upload failed: Printer response code=${result.code}, message=${result.message}`
603+
);
604+
return false;
605+
} catch (error) {
606+
const err = error as Error & {
607+
response?: { status: number; data: GenericResponse };
608+
request?: unknown;
609+
};
610+
console.error(`uploadFileCreator5 error: ${err.message}`);
611+
if (err.response) {
612+
console.error(`Error Status: ${err.response.status}`);
613+
console.error('Error Response Data:', err.response.data);
614+
} else if (err.request) {
615+
console.error('Error Request:', err.request);
616+
}
617+
console.error(err.stack);
618+
return false;
619+
}
527620
}
528621

529622
/**
@@ -553,16 +646,19 @@ export class JobControl {
553646
return false;
554647
}
555648

556-
// Only the fields the /printGcode handler actually reads. fileName and
557-
// levelingBeforePrint are required; the rest are optional.
649+
// Fields the /printGcode handler reads, matching the exact body the FlashForge
650+
// app sends (confirmed via a live C5 capture). The firmware does NOT read
651+
// useMatlStation, gcodeToolCnt, or firstLayerInspection here — those live on the
652+
// upload request (firstLayerInspection doesn't exist on the C5 at all). flowCalibration
653+
// and timeLapseVideo are always present (default false) to mirror the captured body.
558654
const payload: Record<string, unknown> = {
559655
serialNumber: this.client.serialNumber,
560656
checkCode: this.client.checkCode,
561657
fileName: params.fileName,
562658
levelingBeforePrint: params.levelingBeforePrint,
659+
flowCalibration: params.flowCalibration ?? false,
660+
timeLapseVideo: params.timeLapseVideo ?? false,
563661
};
564-
if (params.flowCalibration !== undefined) payload.flowCalibration = params.flowCalibration;
565-
if (params.timeLapseVideo !== undefined) payload.timeLapseVideo = params.timeLapseVideo;
566662
if (hasMappings) payload.materialMappings = params.materialMappings;
567663

568664
try {
@@ -584,7 +680,9 @@ export class JobControl {
584680

585681
/**
586682
* Validates Creator 5 material mappings: toolId 0-3, slotId 1-4, non-empty
587-
* materialName. (No color fields, unlike AD5X.)
683+
* materialName, and #RRGGBB tool/slot colors. The C5 mapping shape is identical
684+
* to the AD5X (confirmed via a live /printGcode capture — it carries the same
685+
* toolMaterialColor / slotMaterialColor fields).
588686
* @param materialMappings Array of Creator 5 mappings to validate.
589687
* @returns True if all mappings are valid, false otherwise.
590688
* @private
@@ -595,6 +693,8 @@ export class JobControl {
595693
return false;
596694
}
597695

696+
const hexColorRegex = /^#[0-9A-Fa-f]{6}$/;
697+
598698
for (let i = 0; i < materialMappings.length; i++) {
599699
const mapping = materialMappings[i];
600700

@@ -618,6 +718,20 @@ export class JobControl {
618718
);
619719
return false;
620720
}
721+
722+
if (!hexColorRegex.test(mapping.toolMaterialColor)) {
723+
console.error(
724+
`Creator 5 material mappings error: toolMaterialColor must be in #RRGGBB format, got ${mapping.toolMaterialColor} at index ${i}`
725+
);
726+
return false;
727+
}
728+
729+
if (!hexColorRegex.test(mapping.slotMaterialColor)) {
730+
console.error(
731+
`Creator 5 material mappings error: slotMaterialColor must be in #RRGGBB format, got ${mapping.slotMaterialColor} at index ${i}`
732+
);
733+
return false;
734+
}
621735
}
622736

623737
return true;

src/api/controls/TempControl.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,5 +335,35 @@ describe('TempControl', () => {
335335
nozzles: [-100, -200, -200, -200],
336336
});
337337
});
338+
339+
// The confirmed C5 payload always carries a 4-entry `nozzles` array, even on a
340+
// bed- or chamber-only command. Unspecified tools default to TEMP_NO_CHANGE so
341+
// the tools are left untouched.
342+
it('setBedTemp still includes a 4-entry nozzles[] array (all unchanged)', async () => {
343+
const result = await c5TempControl.setBedTemp(60);
344+
345+
expect(result).toBe(true);
346+
expect(mockTcpClient.setBedTemp).not.toHaveBeenCalled();
347+
expect(mockSendControlCommand).toHaveBeenCalledWith('temperatureCtl_cmd', {
348+
rightNozzle: -200,
349+
leftNozzle: -200,
350+
platform: 60,
351+
chamber: -200,
352+
nozzles: [-200, -200, -200, -200],
353+
});
354+
});
355+
356+
it('setChamberTemp still includes a 4-entry nozzles[] array (all unchanged)', async () => {
357+
const result = await c5TempControl.setChamberTemp(50);
358+
359+
expect(result).toBe(true);
360+
expect(mockSendControlCommand).toHaveBeenCalledWith('temperatureCtl_cmd', {
361+
rightNozzle: -200,
362+
leftNozzle: -200,
363+
platform: -200,
364+
chamber: 50,
365+
nozzles: [-200, -200, -200, -200],
366+
});
367+
});
338368
});
339369
});

src/api/controls/TempControl.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,14 @@ export class TempControl {
6666
platform: args.platform ?? TEMP_NO_CHANGE,
6767
chamber: args.chamber ?? TEMP_NO_CHANGE,
6868
};
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.
69+
// The `nozzles` array is Creator 5-only (its 4-nozzle tool changer). The
70+
// confirmed C5 payload ALWAYS carries a 4-entry array, even on a bed- or
71+
// chamber-only command, so always include it on the C5 — defaulting unspecified
72+
// tools to TEMP_NO_CHANGE so a bed/chamber adjustment leaves the tools untouched.
7173
if (args.nozzles !== undefined) {
7274
payload.nozzles = args.nozzles;
75+
} else if (this.client.isCreator5) {
76+
payload.nozzles = new Array<number>(NOZZLE_COUNT).fill(TEMP_NO_CHANGE);
7377
}
7478
return await this.client.control.sendControlCommand(Commands.TempControlCmd, payload);
7579
}

0 commit comments

Comments
 (0)