Skip to content

Commit e11b241

Browse files
GhostTypesclaude
andcommitted
feat(creator5): add native /printGcode start-job command
The Creator 5 performs material matching at print-start (POST /printGcode), unlike the AD5X which maps materials at upload time. Add a Creator 5-native start method that sends only the fields the firmware reads. - startCreator5Job(Creator5JobParams): POSTs /printGcode with fileName, levelingBeforePrint, optional flowCalibration/timeLapseVideo, and 3-field Creator5MaterialMapping[] (toolId 0-based, slotId 1-based, materialName). Omits materialMappings for single-tool prints; no AD5X-only fields. - Add Creator5JobParams / Creator5MaterialMapping types + exports. - Replace the AD5X-bodied start aliases (released minutes ago, no consumers) with the native method; keep uploadFileWithMaterialMappings since C5 upload genuinely reuses the AD5X path. - Document Creator 5 support and the library's by-concern organization (do-not-refactor-into-per-printer-clients) in CLAUDE.md. Bump to 1.3.4. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b3c7e45 commit e11b241

6 files changed

Lines changed: 236 additions & 20 deletions

File tree

CLAUDE.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,18 @@ The API uses two communication layers that work together:
4242

4343
Raw API responses (`FFPrinterDetail` in `src/models/ff-models.ts`) are transformed into the structured `FFMachineInfo` model by `MachineInfo.fromDetail()` (`src/models/MachineInfo.ts`). This handles status string-to-enum mapping, time formatting, temperature pairing, and boolean conversion from the printer's "open"/"close" string convention.
4444

45+
### Model Detection (Pid-First)
46+
47+
`MachineInfo.fromDetail()` derives `IsPro` / `IsAD5X` from the firmware-set integer `pid` field on `/detail` (35 = Adventurer 5M, 36 = 5M Pro, 38 = AD5X — see `KNOWN_HTTP_PIDS` in `MachineInfo.ts`). The raw value is also surfaced as `FFMachineInfo.Pid` for consumers that need to do their own model-class gating. When `pid` is absent (older firmware) the parser falls back to a name+capability heuristic, but new code should NOT substring-match `detail.name` — that field is user-mutable via the LCD or cloud account and changing it broke detection in pre-1.3.1 builds (see CHANGELOG entry for 1.3.1, ref `ff-5mp-hass#13`).
48+
49+
### Why TCP Bootstrap Is Still Required for Modern Printers
50+
51+
The HTTP `/detail` endpoint requires authentication (`serialNumber` + `checkCode` via `FNetCode`). During discovery and the first connection attempt — before a check code has been entered — there are no credentials, so `pid` cannot be read from `/detail`. The library bridges this with TCP:
52+
53+
1. `PrinterDiscovery` (UDP) returns the USB-style PID in the broadcast packet and maps it to a `PrinterModel`. Consumers can pre-select a model class before pairing.
54+
2. After a check code is provided, `FiveMClient.initialize()` runs an authenticated `/detail` call alongside an unauthenticated TCP `M115` via `tcpClient.getPrinterInfo()`. M115 returns `TypeName` (firmware-controlled, e.g. `"FlashForge Adventurer 5M Pro"`) which is safe to substring-match; do NOT use M115's `Name` field for capability inference — like `detail.name` it is user-set.
55+
3. Once `verifyConnection()` finishes, `FFMachineInfo.Pid` / `IsPro` / `IsAD5X` are authoritative. All later capability gating should read those, not re-parse strings.
56+
4557
### Network Layer
4658

4759
- `NetworkUtils` (`src/api/network/NetworkUtils.ts`) — Response validation helpers; checks `GenericResponse.code` for success.
@@ -56,6 +68,16 @@ Located in `src/tcpapi/replays/`, each parser has a `fromReplay(response)` metho
5668

5769
The AD5X (Adventurer 5X) extends the 5M API with Intelligent Filament Station (IFS) support. Key types: `AD5XMaterialMapping`, `AD5XLocalJobParams`, `AD5XSingleColorJobParams`, `AD5XUploadParams`, `MatlStationInfo`, `SlotInfo` — all in `src/models/ff-models.ts`.
5870

71+
### Creator 5 / Creator 5 Pro Support
72+
73+
The Creator 5 series is "AD5X + per-tool temps". It shares the 4-slot material station and reuses the AD5X upload path, but **material matching happens at print-start** (`POST /printGcode`) rather than at upload time. Use `startCreator5Job(Creator5JobParams)` — its `materialMappings` are the 3-field `Creator5MaterialMapping` (`toolId` 0-based, `slotId` 1-based, `materialName`; no colors). Per-tool temps are in `FFMachineInfo.ToolTemps[]` (single-nozzle models report a 1-element array). Capability flags: `IsCreator5Pro`, `HasCamera`, `HasLidar`, `HasDoorSensor` (Pro only — plain C5 `doorStatus` is cosmetic). Filtration is force-enabled by model for the C5 Pro in `FiveMClient.sendProductCommand` because its `/product` under-reports the fan states.
74+
75+
## Architecture Note — Organization Axis (future cleanup, NOT urgent)
76+
77+
This library is intentionally organized **by concern** (`Control` / `JobControl` / `Info` / `Files` / `TempControl` / discovery / TCP), exposing a **single `FiveMClient` facade** — not by printer model the way FFUI splits into per-model backends (`AD5XBackend`, `Creator5Backend`, …). That asymmetry is **correct on purpose**: a transport library wins with one capability-based entry point; per-printer *client subclasses* would force a breaking API change and a migration across every consumer (FFUI, FFWebUI) to make the library *worse* to use. **Do not refactor this into per-printer clients.**
78+
79+
The one real smell is that model-specific job logic is accumulating inline in `JobControl.ts` (AD5X + Creator 5). If/when it gets unwieldy, the **non-breaking** fix is to extract per-model job modules *behind the same facade* (e.g. `api/controls/jobs/ad5x.ts`, `api/controls/jobs/creator5.ts`, composed by `JobControl`) — same for `MachineInfo.fromDetail` if model branching grows there. This is optional polish with zero consumer breakage; defer it until it actually hurts, and never bundle it with feature work.
80+
5981
## Key Conventions
6082

6183
- All public exports go through `src/index.ts`

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@ghosttypes/ff-api",
3-
"version": "1.3.3",
3+
"version": "1.3.4",
44
"description": "FlashForge 3D Printer API for Node.js",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",

src/api/controls/JobControl.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,4 +475,78 @@ describe('JobControl', () => {
475475
expect(result).toBe(false);
476476
});
477477
});
478+
479+
describe('startCreator5Job', () => {
480+
beforeEach(() => {
481+
// Creator 5 printer
482+
(mockFiveMClient as { isCreator5: boolean }).isCreator5 = true;
483+
});
484+
485+
it('POSTs the Creator 5-native /printGcode body with 3-field mappings', async () => {
486+
mockedAxios.post.mockResolvedValue({ status: 200, data: { code: 0, message: 'Success' } });
487+
488+
const result = await jobControl.startCreator5Job({
489+
fileName: 'multi.3mf',
490+
levelingBeforePrint: true,
491+
materialMappings: [
492+
{ toolId: 0, slotId: 1, materialName: 'PLA' },
493+
{ toolId: 1, slotId: 3, materialName: 'PETG' },
494+
],
495+
});
496+
497+
expect(result).toBe(true);
498+
expect(mockedAxios.post).toHaveBeenCalledTimes(1);
499+
const [url, body] = mockedAxios.post.mock.calls[0];
500+
expect(url).toBe(`http://printer:8898${Endpoints.GCodePrint}`);
501+
// Only Creator 5 fields — no AD5X-only useMatlStation/gcodeToolCnt/colors.
502+
expect(body).toEqual({
503+
serialNumber: 'SN123456',
504+
checkCode: 'CC123456',
505+
fileName: 'multi.3mf',
506+
levelingBeforePrint: true,
507+
materialMappings: [
508+
{ toolId: 0, slotId: 1, materialName: 'PLA' },
509+
{ toolId: 1, slotId: 3, materialName: 'PETG' },
510+
],
511+
});
512+
expect(body).not.toHaveProperty('useMatlStation');
513+
expect(body).not.toHaveProperty('gcodeToolCnt');
514+
});
515+
516+
it('omits materialMappings for a single-tool print', async () => {
517+
mockedAxios.post.mockResolvedValue({ status: 200, data: { code: 0, message: 'Success' } });
518+
519+
const result = await jobControl.startCreator5Job({
520+
fileName: 'single.gcode',
521+
levelingBeforePrint: false,
522+
});
523+
524+
expect(result).toBe(true);
525+
const [, body] = mockedAxios.post.mock.calls[0];
526+
expect(body).not.toHaveProperty('materialMappings');
527+
});
528+
529+
it('rejects an invalid slotId (must be 1-4) without calling the printer', async () => {
530+
const result = await jobControl.startCreator5Job({
531+
fileName: 'bad.3mf',
532+
levelingBeforePrint: true,
533+
materialMappings: [{ toolId: 0, slotId: 0, materialName: 'PLA' }],
534+
});
535+
536+
expect(result).toBe(false);
537+
expect(mockedAxios.post).not.toHaveBeenCalled();
538+
});
539+
540+
it('refuses to run on a non-material-station printer', async () => {
541+
(mockFiveMClient as { isCreator5: boolean }).isCreator5 = false;
542+
543+
const result = await jobControl.startCreator5Job({
544+
fileName: 'x.gcode',
545+
levelingBeforePrint: true,
546+
});
547+
548+
expect(result).toBe(false);
549+
expect(mockedAxios.post).not.toHaveBeenCalled();
550+
});
551+
});
478552
});

src/api/controls/JobControl.ts

Lines changed: 101 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import type {
1414
AD5XMaterialMapping,
1515
AD5XSingleColorJobParams,
1616
AD5XUploadParams,
17+
Creator5JobParams,
18+
Creator5MaterialMapping,
1719
} from '../../models/ff-models';
1820
import { NetworkUtils } from '../network/NetworkUtils';
1921
import { Endpoints } from '../server/Endpoints';
@@ -498,39 +500,119 @@ export class JobControl {
498500
}
499501
}
500502

501-
// --- Model-neutral material-station aliases ---
502-
// The Creator 5 series shares the AD5X material-mapping print flow, so these
503-
// delegate to the AD5X implementations. Prefer these names in model-agnostic
504-
// code (e.g. a Creator5 backend) so call sites aren't misleadingly "AD5X".
503+
// --- Creator 5 / Creator 5 Pro ---
504+
// The Creator 5 uploads files the same way as the AD5X (the extra IFS headers
505+
// are simply ignored by its firmware), but it performs material matching at
506+
// print-start via POST /printGcode rather than at upload time. So upload reuses
507+
// the AD5X path, while starting a job uses the Creator 5-native body below.
505508

506509
/**
507-
* Uploads a file with material-station mappings (AD5X / Creator 5 series).
508-
* @param params Upload parameters including material mappings.
510+
* Uploads a file to a Creator 5 / Creator 5 Pro. Reuses the AD5X upload path;
511+
* the Creator 5 firmware ignores the IFS-specific headers. To run a multi-tool
512+
* print, upload with `startPrint = false`, then call {@link startCreator5Job}
513+
* with material mappings.
514+
* @param params Upload parameters (material mappings are ignored by the C5 firmware).
509515
* @returns Promise resolving to true on success.
510516
*/
511517
public async uploadFileWithMaterialMappings(params: AD5XUploadParams): Promise<boolean> {
512518
return this.uploadFileAD5X(params);
513519
}
514520

515521
/**
516-
* Starts a multi-color local job using material-station mappings (AD5X / Creator 5 series).
517-
* @param params Job parameters including material mappings.
518-
* @returns Promise resolving to true on success.
522+
* Starts a local print on a Creator 5 / Creator 5 Pro via `POST /printGcode`.
523+
*
524+
* This is the Creator 5's print-start material-matching command (distinct from
525+
* the AD5X, which maps materials at upload time). The file must already be on the
526+
* printer. Provide `materialMappings` for a multi-tool print, or omit them for a
527+
* single-tool print. Sends only the fields the Creator 5 firmware reads.
528+
*
529+
* @param params File name, leveling flag, and optional flags / material mappings.
530+
* @returns Promise resolving to true if the printer accepts the print command.
531+
* @throws Error if there's a network issue sending the command.
519532
*/
520-
public async startMaterialMappingJob(params: AD5XLocalJobParams): Promise<boolean> {
521-
return this.startAD5XMultiColorJob(params);
533+
public async startCreator5Job(params: Creator5JobParams): Promise<boolean> {
534+
if (!this.validateMaterialStationPrinter()) {
535+
return false;
536+
}
537+
538+
if (!params.fileName || params.fileName.trim() === '') {
539+
console.error('Creator 5 Job error: fileName cannot be empty');
540+
return false;
541+
}
542+
543+
const hasMappings = !!params.materialMappings && params.materialMappings.length > 0;
544+
if (hasMappings && !this.validateCreator5MaterialMappings(params.materialMappings ?? [])) {
545+
return false;
546+
}
547+
548+
// Only the fields the /printGcode handler actually reads. fileName and
549+
// levelingBeforePrint are required; the rest are optional.
550+
const payload: Record<string, unknown> = {
551+
serialNumber: this.client.serialNumber,
552+
checkCode: this.client.checkCode,
553+
fileName: params.fileName,
554+
levelingBeforePrint: params.levelingBeforePrint,
555+
};
556+
if (params.flowCalibration !== undefined) payload.flowCalibration = params.flowCalibration;
557+
if (params.timeLapseVideo !== undefined) payload.timeLapseVideo = params.timeLapseVideo;
558+
if (hasMappings) payload.materialMappings = params.materialMappings;
559+
560+
try {
561+
const response = await axios.post(this.client.getEndpoint(Endpoints.GCodePrint), payload, {
562+
headers: {
563+
'Content-Type': 'application/json',
564+
},
565+
});
566+
567+
if (response.status !== 200) return false;
568+
569+
const result = response.data as GenericResponse;
570+
return NetworkUtils.isOk(result);
571+
} catch (error) {
572+
console.error(`Creator 5 Job error: ${(error as Error).message}`);
573+
throw error;
574+
}
522575
}
523576

524577
/**
525-
* Starts a single-color local job on a material-station printer without using the
526-
* station (AD5X / Creator 5 series).
527-
* @param params Job parameters.
528-
* @returns Promise resolving to true on success.
578+
* Validates Creator 5 material mappings: toolId 0-3, slotId 1-4, non-empty
579+
* materialName. (No color fields, unlike AD5X.)
580+
* @param materialMappings Array of Creator 5 mappings to validate.
581+
* @returns True if all mappings are valid, false otherwise.
582+
* @private
529583
*/
530-
public async startSingleColorMaterialStationJob(
531-
params: AD5XSingleColorJobParams
532-
): Promise<boolean> {
533-
return this.startAD5XSingleColorJob(params);
584+
private validateCreator5MaterialMappings(materialMappings: Creator5MaterialMapping[]): boolean {
585+
if (materialMappings.length > 4) {
586+
console.error('Creator 5 material mappings error: Maximum 4 material mappings allowed');
587+
return false;
588+
}
589+
590+
for (let i = 0; i < materialMappings.length; i++) {
591+
const mapping = materialMappings[i];
592+
593+
if (mapping.toolId < 0 || mapping.toolId > 3) {
594+
console.error(
595+
`Creator 5 material mappings error: toolId must be between 0-3, got ${mapping.toolId} at index ${i}`
596+
);
597+
return false;
598+
}
599+
600+
if (mapping.slotId < 1 || mapping.slotId > 4) {
601+
console.error(
602+
`Creator 5 material mappings error: slotId must be between 1-4, got ${mapping.slotId} at index ${i}`
603+
);
604+
return false;
605+
}
606+
607+
if (!mapping.materialName || mapping.materialName.trim() === '') {
608+
console.error(
609+
`Creator 5 material mappings error: materialName cannot be empty at index ${i}`
610+
);
611+
return false;
612+
}
613+
}
614+
615+
return true;
534616
}
535617

536618
/**

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ export {
4343
AD5XMaterialMapping,
4444
AD5XSingleColorJobParams,
4545
AD5XUploadParams,
46+
Creator5JobParams,
47+
Creator5MaterialMapping,
4648
FFGcodeFileEntry,
4749
FFGcodeToolData,
4850
FFMachineInfo,

src/models/ff-models.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,42 @@ export interface AD5XSingleColorJobParams {
458458
levelingBeforePrint: boolean;
459459
}
460460

461+
/**
462+
* Material mapping for a Creator 5 / Creator 5 Pro multi-tool print.
463+
*
464+
* Unlike {@link AD5XMaterialMapping}, the Creator 5 performs material matching at
465+
* print-start (`POST /printGcode`) and its mapping carries only three fields — no
466+
* tool/slot colors. The firmware stores `toolId + 1` internally.
467+
*/
468+
export interface Creator5MaterialMapping {
469+
/** Gcode tool / extruder index, 0-based (T0–T3). */
470+
toolId: number;
471+
/** Physical material-station slot to source from, 1-based (1–4). */
472+
slotId: number;
473+
/** Material name (e.g. "PLA"). */
474+
materialName: string;
475+
}
476+
477+
/**
478+
* Parameters for starting a local print on a Creator 5 / Creator 5 Pro via
479+
* `POST /printGcode`. The file must already be on the printer (upload first).
480+
*
481+
* For a single-tool print, omit `materialMappings`. For a multi-tool print, map
482+
* each gcode tool to a slot + material.
483+
*/
484+
export interface Creator5JobParams {
485+
/** Name of the file already stored on the printer. */
486+
fileName: string;
487+
/** Whether to bed-level before printing (required by the firmware). */
488+
levelingBeforePrint: boolean;
489+
/** Optional: enable flow calibration. */
490+
flowCalibration?: boolean;
491+
/** Optional: record a time-lapse video. */
492+
timeLapseVideo?: boolean;
493+
/** Optional per-tool material mappings (1–4). Omit for a single-tool print. */
494+
materialMappings?: Creator5MaterialMapping[];
495+
}
496+
461497
/**
462498
* Parameters for uploading a file to AD5X printer with material station support.
463499
* Extends basic upload functionality with AD5X-specific features like material mappings,

0 commit comments

Comments
 (0)