Skip to content

Commit b91dc35

Browse files
committed
fix(creator5): correct msConfig color and temperatureCtl
1 parent 3d07107 commit b91dc35

7 files changed

Lines changed: 462 additions & 6 deletions

File tree

src/api/controls/Control.test.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -596,13 +596,16 @@ describe('Control', () => {
596596

597597
const result = await control.configureSlot(2, 'PETG', '#46328E');
598598

599+
// Creator 5: color must be an exact firmware palette match — uppercase
600+
// "#RRGGBB" WITH the leading "#". #46328E is off-palette and snaps to the
601+
// nearest C5 entry, Purple (#48358C). NOT the stripped AD5X value.
599602
expect(result).toBe(true);
600603
expect(mockedAxios.post).toHaveBeenCalledWith(
601604
`http://printer:8898${Endpoints.Control}`,
602605
expect.objectContaining({
603606
payload: {
604607
cmd: Commands.MaterialStationConfigCmd,
605-
args: { slot: 2, mt: 'PETG', rgb: '46328E' },
608+
args: { slot: 2, mt: 'PETG', rgb: '#48358C' },
606609
},
607610
}),
608611
expect.any(Object)
@@ -620,6 +623,73 @@ describe('Control', () => {
620623
});
621624
});
622625

626+
// Creator 5 / Creator 5 Pro slot color: the firmware renders an icon ONLY on a
627+
// byte-for-byte, case-sensitive match against its 24-entry palette (WITH the
628+
// "#"). configureSlot must snap the caller's color to the nearest palette entry
629+
// and emit uppercase "#RRGGBB" — never an off-list or stripped value.
630+
describe('Creator 5 material-station slot color (palette snapping)', () => {
631+
const paletteHexes = [
632+
'#FFFFFF', '#FFF245', '#DEF578', '#21CC3D', '#167A4B', '#156682', '#24E4A0',
633+
'#7BD9F0', '#4CAAF8', '#2E54DD', '#48358C', '#A341F7', '#F435F6', '#D5B4DE',
634+
'#FA6173', '#F82D29', '#805003', '#F9903B', '#FCEBD7', '#D5C5A1', '#B17C38',
635+
'#8C8C89', '#BEBEBE', '#1B1B1B',
636+
];
637+
638+
const captureRgb = async (hexRgb: string): Promise<string> => {
639+
mockedAxios.post.mockClear();
640+
await control.configureSlot(1, 'PLA', hexRgb);
641+
const call = mockedAxios.post.mock.calls[0];
642+
const payload = call[1] as { payload: { args: { rgb: string } } };
643+
return payload.payload.args.rgb;
644+
};
645+
646+
beforeEach(() => {
647+
mockedAxios.post.mockResolvedValue({ status: 200, data: { code: 0, message: 'Success' } });
648+
mockFiveMClient.isAD5X = false;
649+
mockFiveMClient.isCreator5 = true;
650+
});
651+
652+
it('always snaps to a value from the 24-entry C5 palette (never off-list)', async () => {
653+
const inputs = ['#FF0000', '#123456', '#00FF00', '#ABCDEF', '#A341F7', '#112233', '#FEDCBA'];
654+
for (const input of inputs) {
655+
const rgb = await captureRgb(input);
656+
expect(paletteHexes).toContain(rgb);
657+
}
658+
});
659+
660+
it('always emits uppercase hex with a leading "#"', async () => {
661+
const rgb = await captureRgb('#ff0000');
662+
expect(rgb.startsWith('#')).toBe(true);
663+
expect(rgb).toBe(rgb.toUpperCase());
664+
expect(rgb).toMatch(/^#[0-9A-F]{6}$/);
665+
});
666+
667+
it('snaps #FF0000 (pure red) to #F82D29 (nearest palette red)', async () => {
668+
expect(await captureRgb('#FF0000')).toBe('#F82D29');
669+
});
670+
671+
it('snaps an exact palette entry to itself (case-normalized)', async () => {
672+
expect(await captureRgb('#4CAAF8')).toBe('#4CAAF8');
673+
expect(await captureRgb('#4caaf8')).toBe('#4CAAF8');
674+
expect(await captureRgb('4caaF8')).toBe('#4CAAF8');
675+
});
676+
677+
it('snaps white to #FFFFFF', async () => {
678+
expect(await captureRgb('#FFFFFF')).toBe('#FFFFFF');
679+
expect(await captureRgb('#FFF')).toBe('#FFFFFF');
680+
});
681+
682+
// REGRESSION GUARD: the AD5X path must remain unchanged — freeform hex with
683+
// the leading "#" stripped, no palette snapping.
684+
it('AD5X path is unchanged: strips "#" and keeps freeform hex (no snapping)', async () => {
685+
mockFiveMClient.isAD5X = true;
686+
mockFiveMClient.isCreator5 = false;
687+
688+
expect(await captureRgb('#FF0000')).toBe('FF0000');
689+
expect(await captureRgb('#46328E')).toBe('46328E');
690+
});
691+
});
692+
623693
describe('sendJobControlCmd', () => {
624694
it('should send job control command', async () => {
625695
mockedAxios.post.mockResolvedValue({

src/api/controls/Control.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { Filament } from '../filament/Filament';
1212
import { NetworkUtils } from '../network/NetworkUtils';
1313
import { Commands } from '../server/Commands';
1414
import { Endpoints } from '../server/Endpoints';
15+
import { snapToCreator5Palette } from './creator5Palette';
1516

1617
/**
1718
* Represents printer status information.
@@ -258,13 +259,17 @@ export class Control {
258259
* that **filament load/unload (`slotAction`) remains AD5X-only** — the Creator 5 firmware
259260
* has no `ms_cmd`, so {@link slotAction} stays gated to the AD5X.
260261
*
261-
* The firmware accepts arbitrary material/color strings, but only the recognized
262-
* materials and the 24-color UI palette render an icon on the printer — callers that
263-
* want a clean display should map to the nearest recognized values first.
262+
* The firmware accepts arbitrary material strings, but the two models render
263+
* the slot color icon differently:
264+
* - AD5X: accepts freeform hex (the leading "#" is stripped before sending).
265+
* - Creator 5 / 5 Pro: renders an icon ONLY when `rgb` is a byte-for-byte,
266+
* case-sensitive match against the firmware's 24-entry palette (WITH the
267+
* "#"); any other value falls back to White. This method snaps the caller's
268+
* color to the nearest palette entry automatically for the Creator 5.
264269
*
265270
* @param slot The slot number (1-4).
266271
* @param materialName The material type (e.g., "PLA", "PETG").
267-
* @param hexRgb The color as a hex string; a leading "#" is stripped to match the wire format ("RRGGBB").
272+
* @param hexRgb The color as a hex string.
268273
* @returns A Promise that resolves to true if the command is successful, false otherwise.
269274
*/
270275
public async configureSlot(
@@ -276,10 +281,19 @@ export class Control {
276281
console.log('configureSlot() error, material station only available on AD5X / Creator 5.');
277282
return false;
278283
}
284+
// The AD5X and Creator 5 use MUTUALLY EXCLUSIVE color wire formats (see
285+
// creator5Palette for the firmware match rules), so model-gate here:
286+
// - AD5X: freeform hex, the leading "#" stripped ("RRGGBB"). Unchanged.
287+
// - Creator 5 / 5 Pro: the firmware renders an icon ONLY on a byte-for-byte
288+
// match against its 24-entry palette (case-sensitive, WITH the "#"). Snap
289+
// the caller's color to the nearest palette entry in uppercase "#RRGGBB".
290+
const rgb = this.client.isCreator5
291+
? snapToCreator5Palette(hexRgb).hex
292+
: hexRgb.replace(/^#/, '');
279293
return await this.sendControlCommand(Commands.MaterialStationConfigCmd, {
280294
slot,
281295
mt: materialName,
282-
rgb: hexRgb.replace(/^#/, ''),
296+
rgb,
283297
});
284298
}
285299

src/api/controls/TempControl.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,4 +286,54 @@ describe('TempControl', () => {
286286
expect(mockSendControlCommand).not.toHaveBeenCalled();
287287
});
288288
});
289+
290+
// Creator 5 (isCreator5) drives its tools ONLY via the `nozzles` array; the
291+
// firmware's doTemperatureControl handler never reads rightNozzle/leftNozzle.
292+
// The generic setExtruderTemp/cancelExtruderTemp must therefore emit a 4-element
293+
// `nozzles` array targeting the primary tool (T0) rather than rightNozzle.
294+
// The describe above (httpOnly without isCreator5) covers the AD5X / 5M path,
295+
// which keeps rightNozzle — a deliberate regression guard.
296+
describe('Creator 5 extruder temp (httpOnly, per-tool nozzles[])', () => {
297+
let mockSendControlCommand: ReturnType<typeof vi.fn>;
298+
let c5TempControl: TempControl;
299+
300+
beforeEach(() => {
301+
mockSendControlCommand = vi.fn().mockResolvedValue(true);
302+
const httpClient = {
303+
httpOnly: true,
304+
isCreator5: true,
305+
tcpClient: mockTcpClient,
306+
control: { sendControlCommand: mockSendControlCommand },
307+
} as unknown as FiveMClient;
308+
c5TempControl = new TempControl(httpClient);
309+
});
310+
311+
it('setExtruderTemp drives T0 via nozzles[] and does NOT send rightNozzle', async () => {
312+
const result = await c5TempControl.setExtruderTemp(215);
313+
314+
expect(result).toBe(true);
315+
expect(mockTcpClient.setExtruderTemp).not.toHaveBeenCalled();
316+
expect(mockSendControlCommand).toHaveBeenCalledWith('temperatureCtl_cmd', {
317+
rightNozzle: -200, // TEMP_NO_CHANGE — not set
318+
leftNozzle: -200,
319+
platform: -200,
320+
chamber: -200,
321+
nozzles: [215, -200, -200, -200],
322+
});
323+
});
324+
325+
it('cancelExtruderTemp turns T0 off via nozzles[] and does NOT send rightNozzle', async () => {
326+
const result = await c5TempControl.cancelExtruderTemp();
327+
328+
expect(result).toBe(true);
329+
expect(mockTcpClient.cancelExtruderTemp).not.toHaveBeenCalled();
330+
expect(mockSendControlCommand).toHaveBeenCalledWith('temperatureCtl_cmd', {
331+
rightNozzle: -200, // TEMP_NO_CHANGE — not set
332+
leftNozzle: -200,
333+
platform: -200,
334+
chamber: -200,
335+
nozzles: [-100, -200, -200, -200],
336+
});
337+
});
338+
});
289339
});

src/api/controls/TempControl.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,17 @@ export class TempControl {
138138
*/
139139
public async setExtruderTemp(temp: number): Promise<boolean> {
140140
if (this.client.httpOnly) {
141+
// Creator 5 tools are driven ONLY via the `nozzles` array — the firmware's
142+
// doTemperatureControl handler never reads rightNozzle/leftNozzle. Target
143+
// the primary tool (T0): the active tool isn't reliably known over HTTP, so
144+
// callers that need a specific tool should use setToolTemp(index, temp).
145+
if (this.client.isCreator5) {
146+
const nozzles = this.buildNozzleArray(0, temp);
147+
if (nozzles === null) return false;
148+
return await this.sendHttpTempCommand({ nozzles });
149+
}
150+
// Single-tool AD5X / 5M only reachable here if forced into httpOnly mode;
151+
// they keep the rightNozzle field (unchanged).
141152
return await this.sendHttpTempCommand({ rightNozzle: temp });
142153
}
143154
return await this.tcpClient.setExtruderTemp(temp);
@@ -161,6 +172,14 @@ export class TempControl {
161172
*/
162173
public async cancelExtruderTemp(): Promise<boolean> {
163174
if (this.client.httpOnly) {
175+
// See setExtruderTemp: Creator 5 tools are driven only via `nozzles`. Turn
176+
// the primary tool (T0) off while leaving the other tools unchanged.
177+
if (this.client.isCreator5) {
178+
const nozzles = this.buildNozzleArray(0, TEMP_OFF);
179+
if (nozzles === null) return false;
180+
return await this.sendHttpTempCommand({ nozzles });
181+
}
182+
// Single-tool AD5X / 5M (forced httpOnly) keep rightNozzle (unchanged).
164183
return await this.sendHttpTempCommand({ rightNozzle: TEMP_OFF });
165184
}
166185
return await this.tcpClient.cancelExtruderTemp();
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* @fileoverview Unit tests for the Creator 5 palette + nearest-color snapping.
3+
* Verifies the CIEDE2000 perceptual snap produces byte-for-byte firmware palette
4+
* matches (exact, case-sensitive "#RRGGBB") for the msConfig_cmd wire format.
5+
*/
6+
import { describe, expect, it } from 'vitest';
7+
import { CREATOR5_PALETTE, snapToCreator5Palette } from './creator5Palette';
8+
9+
describe('snapToCreator5Palette', () => {
10+
const paletteHexes = CREATOR5_PALETTE.map((c) => c.hex);
11+
12+
it('the palette has exactly 24 entries, all uppercase "#RRGGBB", index 0 = White', () => {
13+
expect(CREATOR5_PALETTE).toHaveLength(24);
14+
for (const c of CREATOR5_PALETTE) {
15+
expect(c.hex).toMatch(/^#[0-9A-F]{6}$/);
16+
expect(c.hex).toBe(c.hex.toUpperCase());
17+
}
18+
expect(CREATOR5_PALETTE[0]).toEqual({ index: 0, name: 'White', hex: '#FFFFFF' });
19+
});
20+
21+
it('every palette entry snaps to itself', () => {
22+
for (const c of CREATOR5_PALETTE) {
23+
expect(snapToCreator5Palette(c.hex).hex).toBe(c.hex);
24+
}
25+
});
26+
27+
it('never returns an off-palette value', () => {
28+
const inputs = ['#FF0000', '#123456', '#00FF00', '#ABCDEF', '#112233', '#FEDCBA', '#8080FF'];
29+
for (const input of inputs) {
30+
const snapped = snapToCreator5Palette(input).hex;
31+
expect(paletteHexes).toContain(snapped);
32+
}
33+
});
34+
35+
it('always returns uppercase "#RRGGBB" with the leading "#"', () => {
36+
for (const input of ['#ff0000', 'ff0000', '#4caaf8', '4CAAf8', '#abc']) {
37+
const snapped = snapToCreator5Palette(input).hex;
38+
expect(snapped).toMatch(/^#[0-9A-F]{6}$/);
39+
}
40+
});
41+
42+
it('snaps pure red #FF0000 to palette Red #F82D29', () => {
43+
expect(snapToCreator5Palette('#FF0000').hex).toBe('#F82D29');
44+
});
45+
46+
it('snaps an exact palette entry to itself regardless of input case/shape', () => {
47+
expect(snapToCreator5Palette('#4CAAF8').hex).toBe('#4CAAF8');
48+
expect(snapToCreator5Palette('#4caaf8').hex).toBe('#4CAAF8');
49+
expect(snapToCreator5Palette('4caaF8').hex).toBe('#4CAAF8');
50+
});
51+
52+
it('snaps white to #FFFFFF (3-digit shorthand too)', () => {
53+
expect(snapToCreator5Palette('#FFFFFF').hex).toBe('#FFFFFF');
54+
expect(snapToCreator5Palette('#FFF').hex).toBe('#FFFFFF');
55+
});
56+
57+
it('falls back to White (index 0) on unparseable input', () => {
58+
expect(snapToCreator5Palette('not-a-color')).toEqual(CREATOR5_PALETTE[0]);
59+
expect(snapToCreator5Palette('')).toEqual(CREATOR5_PALETTE[0]);
60+
});
61+
});

0 commit comments

Comments
 (0)