diff --git a/.gitignore b/.gitignore index b8c8ff8..3964bc7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ package-lock.json .kiro/coverage/ coverage/ nul + +# Local codegraph index (tooling artifact) +.codegraph/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ef01eb..3efc6ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [1.7.1] - 2026-07-26 + +### Fixed + +- **`FFMachineInfo.HasMatlStation` no longer misses the Material Station on the Creator 5 series.** It was a straight copy of the raw `hasMatlStation` value from `/detail`, which is an AD5X-only field — a Creator 5 Pro omits it entirely (verified on real hardware, pid 41) while reporting a fully populated `matlStationInfo` with four loaded slots. It therefore arrived `undefined`, and consumers gating on it saw no station on exactly the models that have one. `fromDetail` already computed the correct value for its own AD5X heuristic (flag `=== true` OR `slotCnt > 0` OR non-empty `slotInfos`) and then discarded it; that derived value is now what the field exposes. + +### Changed + +- **`FFMachineInfo.HasMatlStation` is now a required `boolean`** rather than `boolean | undefined`. A capability has no "unknown" state, and offering one is the mechanism of the bug above: firmware omits what does not apply, so an absent field reads as `undefined`, and `undefined` reads as "no". `FFPrinterDetail.hasMatlStation` keeps the untouched firmware value and stays optional, because there the absence *is* the information. Only `fromDetail` constructs an `FFMachineInfo`, so consumers reading the field are unaffected. ## [1.7.0] - 2026-07-24 @@ -195,7 +203,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - AD5X job info parsing returning incomplete data - `NetworkUtils.isOk` usage corrected across response handlers -[Unreleased]: https://github.com/GhostTypes/ff-5mp-api-ts/compare/v1.3.0...HEAD +[Unreleased]: https://github.com/GhostTypes/ff-5mp-api-ts/compare/v1.7.1...HEAD +[1.7.1]: https://github.com/GhostTypes/ff-5mp-api-ts/compare/v1.7.0...v1.7.1 [1.3.0]: https://github.com/GhostTypes/ff-5mp-api-ts/compare/v1.2.0...v1.3.0 [1.2.0]: https://github.com/GhostTypes/ff-5mp-api-ts/compare/v1.1.0...v1.2.0 [1.1.0]: https://github.com/GhostTypes/ff-5mp-api-ts/compare/v1.0.0...v1.1.0 diff --git a/CLAUDE.md b/CLAUDE.md index 1b2098b..45ff85e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -A TypeScript API library (`@ghosttypes/ff-api`) for controlling FlashForge 3D printers, reverse-engineered from FlashForge software communication. Supports Adventurer 5M/5M Pro, AD5X, and legacy Adventurer 3/4 printers. Published to GitHub Packages (npm). +A TypeScript API library (`@ghosttypes/ff-api`) for controlling FlashForge 3D printers, reverse-engineered from FlashForge software communication. Supports Adventurer 5M/5M Pro, AD5X, Creator 5 / Creator 5 Pro, and legacy Adventurer 3/4 printers. Published to GitHub Packages (npm). ## Build & Test Commands @@ -22,7 +22,7 @@ The API uses two communication layers that work together: 1. **HTTP API** (port 8898) — Modern REST-like API for 5M/5M Pro/AD5X printers. Uses `axios` with JSON payloads authenticated via `serialNumber` + `checkCode`. Endpoints defined in `src/api/server/Endpoints.ts`, command types in `src/api/server/Commands.ts`. -2. **TCP API** (port 8899) — Legacy G-code/M-code protocol over raw TCP sockets. Used for all printer models and also embedded within `FiveMClient` for operations not available over HTTP (direct G-code, homing, temperature control). Commands defined in `src/tcpapi/client/GCodes.ts`. +2. **TCP API** (port 8899) — Legacy G-code/M-code protocol over raw TCP sockets. Used for the 5M family, AD5X, and legacy Adventurer 3/4 printers, and embedded within `FiveMClient` for operations not available over HTTP (direct G-code, homing, temperature control). The **Creator 5 / Creator 5 Pro run no TCP server** (no port 8899) and are HTTP-only — see *HTTP-Only Mode* below. Commands defined in `src/tcpapi/client/GCodes.ts`. ### Client Hierarchy @@ -31,13 +31,23 @@ The API uses two communication layers that work together: - `jobControl` (JobControl) — Start/stop/pause/resume print jobs - `info` (Info) — Printer details, machine state - `files` (Files) — File listing, upload, thumbnails - - `tempControl` (TempControl) — Temperature control via HTTP + - `tempControl` (TempControl) — Extruder/bed temperature via HTTP; on Creator 5 also per-tool temps (`setToolTemps` / `setToolTemp` / `cancelToolTemp`) and chamber heater (`setChamberTemp` / `cancelChamberTemp`) - `tcpClient` (FlashForgeClient) — Direct G-code access - **`FlashForgeClient`** (`src/tcpapi/FlashForgeClient.ts`) — High-level TCP client for legacy printers. Extends `FlashForgeTcpClient` with parsed G-code commands. Only requires IP address. - **`FlashForgeTcpClient`** (`src/tcpapi/FlashForgeTcpClient.ts`) — Low-level TCP socket management with keep-alive, command serialization, and multi-line response parsing. +### HTTP-Only Mode (Creator 5 / 5 Pro) + +The Creator 5 / Creator 5 Pro ship **no legacy TCP server** (no port 8899) and are driven entirely over HTTP via `OrcaServer` on 8898. `FiveMClient` models this with an `httpOnly` flag: + +- **`FiveMClientConnectionOptions.httpOnly?: boolean`** and the public `FiveMClient.httpOnly`. It is **auto-enabled** in `verifyConnection()` when a Creator 5 / 5 Pro is detected from `/detail`, and can also be set explicitly up front (e.g. from `PrinterDiscovery`'s USB PID). +- In HTTP-only mode: `initControl()` succeeds on the HTTP `/product` command alone (never opens TCP); `verifyConnection()` skips the TCP `getPrinterInfo()` probe; `dispose()` skips TCP teardown. +- TCP-only `Control` operations (`homeAxes`, `homeAxesRapid`, runout sensor, and the filament load/unload family) return `false` with a log instead of hanging on a dead socket. `files.getLocalFileList()` falls back to HTTP `/gcodeList` (10 most-recent files). + +There is **no raw G/M-code passthrough and no axis move/jog** for Creator 5 — its LAN command set is HTTP-only (verified by Ghidra against firmware `firmwareExe` 1.9.2). + ### Data Flow 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. @@ -54,6 +64,10 @@ The HTTP `/detail` endpoint requires authentication (`serialNumber` + `checkCode 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. 3. Once `verifyConnection()` finishes, `FFMachineInfo.Pid` / `IsPro` / `IsAD5X` are authoritative. All later capability gating should read those, not re-parse strings. +**Never expose a raw `/detail` field as a capability, and never give a capability an `undefined` state.** Firmware omits what does not apply to a model, so an absent value means "not reported", not "no" — and an optional boolean invites a consumer to read the two as the same thing. `hasMatlStation` is the case that bit us: AD5X-only, absent from a Creator 5 Pro that has four loaded slots, so `HasMatlStation` arrived `undefined` and consumers gating on it saw no station on exactly the models that have one. `MachineInfo.fromDetail` therefore **derives** it (`=== true` OR `slotCnt > 0` OR non-empty `slotInfos`) and the field is a required `boolean`; the raw value stays on `FFPrinterDetail`, where its absence *is* the information. Apply this to any new capability: derive it from the data the capability actually produces, give it no unknown state, and cover it with a fixture built from a real payload. + +> **Exception — Creator 5 / 5 Pro:** these models have no TCP server at all, so there is no M115 probe and no TCP bootstrap step. They are HTTP-only from the first connection (see *HTTP-Only Mode* above). + ### Network Layer - `NetworkUtils` (`src/api/network/NetworkUtils.ts`) — Response validation helpers; checks `GenericResponse.code` for success. @@ -70,7 +84,7 @@ The AD5X (Adventurer 5X) extends the 5M API with Intelligent Filament Station (I ### Creator 5 / Creator 5 Pro Support -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. +The Creator 5 series is "AD5X + per-tool temps + chamber heater". It shares the 4-slot material station and reuses the AD5X upload path (`uploadFileCreator5`), but **material matching happens at print-start** (`POST /printGcode`, via `startCreator5Job(Creator5JobParams)`) rather than at upload time. `Creator5MaterialMapping` matches the AD5X shape — `toolId` (0-based), `slotId` (1-based), `materialName`, plus **required** `toolMaterialColor` / `slotMaterialColor` (`#RRGGBB`); the colorless 3-field form used in early 1.3.x builds was replaced in 1.6.0. Per-tool temps are read from `FFMachineInfo.ToolTemps[]` and set via `tempControl.setToolTemps()` / `setToolTemp()` / `cancelToolTemp()`; nozzle count is `FFMachineInfo.NozzleCount` (C5 = 4, single-nozzle = 1) and the heated chamber is `FFMachineInfo.Chamber`, controlled via `tempControl.setChamberTemp()` / `cancelChamberTemp()`. `Control.configureSlot()` runs on `isAD5X || isCreator5` — on C5 (no removable IFS) it sets per-tool material name/color metadata, with RGB handling model-split: AD5X accepts freeform hex, Creator 5 snaps through `snapToCreator5Palette()` (see `src/api/controls/creator5Palette.ts`) because the `msConfig_cmd` icon only renders on a byte-for-byte, case-sensitive match to one of 24 firmware palette strings. Capability flags: `IsCreator5Pro`, `HasCamera`, `HasLidar`, `HasDoorSensor` (Pro only — plain C5 `doorStatus` is cosmetic; "close" means lid **and** front door closed). Filtration is force-enabled by model for the C5 Pro in `FiveMClient.sendProductCommand` because its `/product` under-reports the fan states. ## Architecture Note — Organization Axis (future cleanup, NOT urgent) diff --git a/package.json b/package.json index 404835e..295fa24 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ghosttypes/ff-api", - "version": "1.7.0", + "version": "1.7.1", "description": "FlashForge 3D Printer API for Node.js", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/models/MachineInfo.test.ts b/src/models/MachineInfo.test.ts index 4f9a8f3..d56cb25 100644 --- a/src/models/MachineInfo.test.ts +++ b/src/models/MachineInfo.test.ts @@ -188,7 +188,8 @@ describe('MachineInfo', () => { expect(result.FirmwareVersion).toBe('1.0.0'); expect(result.CameraStreamUrl).toBe(''); - expect(result.HasMatlStation).toBeUndefined(); + // False, not undefined: the capability is answered, not passed through. + expect(result.HasMatlStation).toBe(false); expect(result.MatlStationInfo).toBeUndefined(); expect(result.IndepMatlInfo).toBeUndefined(); expect(result.CoolingFanLeftSpeed).toBeUndefined(); @@ -356,6 +357,27 @@ describe('MachineInfo', () => { }, }; + it('reports the material station on a Creator 5 Pro, which never sends the flag', () => { + // Regression test: HasMatlStation used to be a copy of detail.hasMatlStation, + // which the Creator 5 series does not send at all - note its absence from + // CREATOR5_PRO_DETAIL_JSON above, exactly as the hardware reports it. The + // flag arrived undefined, and every consumer gating on it concluded there + // was no station while matlStationInfo listed four loaded slots. + expect(CREATOR5_PRO_DETAIL_JSON.hasMatlStation).toBeUndefined(); + + const result = machineInfoConverter.fromDetail(CREATOR5_PRO_DETAIL_JSON); + + expect(result).not.toBeNull(); + if (!result) return; + + expect(result.HasMatlStation).toBe(true); + expect(result.MatlStationInfo?.slotCnt).toBe(4); + expect(result.MatlStationInfo?.slotInfos).toHaveLength(4); + // Deriving the station must not drag a Creator 5 into AD5X detection. + expect(result.IsAD5X).toBe(false); + expect(result.IsCreator5Pro).toBe(true); + }); + it('parses Creator 5 Pro per-tool temps and Pro-only capabilities', () => { const result = machineInfoConverter.fromDetail(CREATOR5_PRO_DETAIL_JSON); diff --git a/src/models/MachineInfo.ts b/src/models/MachineInfo.ts index 4f11817..12e44f9 100644 --- a/src/models/MachineInfo.ts +++ b/src/models/MachineInfo.ts @@ -183,8 +183,11 @@ export class MachineInfo { HasLidar: hasLidar, HasDoorSensor: hasDoorSensor, - // Material Station Info - HasMatlStation: detail.hasMatlStation, + // Material Station Info. The DERIVED value, not detail.hasMatlStation: + // that field is AD5X-only and the Creator 5 series omits it entirely + // even with four loaded slots, so the raw value is undefined on exactly + // the models that have a station. + HasMatlStation: hasMaterialStation, MatlStationInfo: detail.matlStationInfo, // Assign directly IndepMatlInfo: detail.indepMatlInfo, // Assign directly diff --git a/src/models/ff-models.ts b/src/models/ff-models.ts index db53ddf..fd52ffd 100644 --- a/src/models/ff-models.ts +++ b/src/models/ff-models.ts @@ -321,8 +321,17 @@ export interface FFMachineInfo { /** Formatted string of the printer's total accumulated run time (e.g., "Xh:Ym"). */ FormattedTotalRunTime: string; - /** Indicates if the printer has a material station. */ - HasMatlStation?: boolean; + /** + * Indicates if the printer has a material station. + * + * Derived by {@link MachineInfo.fromDetail} from the station data, NOT copied + * from the raw `hasMatlStation` field - that one is AD5X-only and the Creator 5 + * series never reports it, station attached or not. Required and never + * undefined on purpose: a capability has no unknown state, and offering one is + * what let an unreported flag read as absent hardware. For the untouched + * firmware value, read {@link FFPrinterDetail.hasMatlStation}. + */ + HasMatlStation: boolean; /** Detailed information about the material station, if present. */ MatlStationInfo?: MatlStationInfo; // Using the raw type directly for now /** Information about independent material loading. */