diff --git a/CLAUDE.md b/CLAUDE.md index 8e25bcb..f745545 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -286,6 +286,11 @@ Some printers support **both** (dual API). The backend system abstracts these di **Feature Detection**: Each backend declares supported features via `getBaseFeatures()`. The UI shows or hides controls based on those features, including LEDs, power toggle, material station support, and camera support. +**Model Detection (TCP-First Bootstrap, PID-Aware)**: +- The HTTP `/detail` endpoint on modern printers requires authentication (`serialNumber` + `checkCode`), so during the very first connection — before the user has supplied a check code — we cannot read the firmware-set `pid` from `/detail`. `ConnectionEstablishmentService.ts` therefore opens an unauthenticated TCP `M115` first via `tcpClient.getPrinterInfo()` and feeds the resulting `TypeName` (firmware-controlled, e.g. `"FlashForge Adventurer 5M Pro"`) into `detectPrinterModelType` / `detectPrinterFamily` in `src/utils/PrinterUtils.ts` for backend selection. This is correct and intentional — `TypeName` is firmware-set and is NOT the same as the user-mutable `Name` field on `/detail`. +- **Once paired, trust the library.** After the check code is supplied and `FiveMClient.initialize()` succeeds, `client.isPro` / `client.isAD5X` / `info.Pid` (from `@ghosttypes/ff-api>=1.3.1`) are derived from the firmware `pid` (35 = 5M, 36 = 5M Pro, 38 = AD5X). Read those flags for capability gating; do not re-substring-match `info.Name` — that field is user-set via the LCD or cloud and changing it broke detection in pre-fix builds (`ff-5mp-hass#13`). +- **Don't manually overwrite `client.isAD5X`.** If you find yourself re-deriving capability flags that the library already sets, prefer fixing the library or the backend-selection input over mutating the FiveMClient instance from app code. + ## Event Flow 1. **Startup** (`src/index.ts`) diff --git a/README.md b/README.md index d802048..a4840dd 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ FlashForge WebUI supports a wide range of FlashForge printers through its adapta | --- | --- | --- | | **Adventurer 5M** | Adventurer 5M, 5M Pro | New (HTTP API) | | **AD5X** | AD5X | New (HTTP API) | +| **Creator 5** | Creator 5, Creator 5 Pro | New (HTTP API) | | **Legacy** | Older FlashForge Models | Legacy (FlashForgeClient) | diff --git a/docs/SPOOLMAN-SLOT-BLUEPRINT.md b/docs/SPOOLMAN-SLOT-BLUEPRINT.md new file mode 100644 index 0000000..18bc08f --- /dev/null +++ b/docs/SPOOLMAN-SLOT-BLUEPRINT.md @@ -0,0 +1,155 @@ +# Blueprint: Set an AD5X IFS slot's material/color from a Spoolman spool + +**Status:** not implemented here yet — this is a porting guide. +**Origin:** pioneered in the Android app (`flashforgeui-app`); this is the backport. +**Difference from Android:** Android scans an NFC-tagged spool. Here there's no NFC, so the user +**picks a spool from a list** (the existing Spoolman spool selector) instead. + +--- + +## 1. What we're building + +On the AD5X IFS slot editor, add a **"Set from Spoolman"** affordance. The flow: + +1. User opens a slot's editor and taps **Set from Spoolman**. +2. A **spool picker** opens — a searchable list of the user's Spoolman spools (reuse the existing + spool-selection UI; see §4). +3. User picks a spool. We read its `material` + `color_hex` from Spoolman. +4. We **snap** those to the printer's fixed lists (see §3) — the AD5X only renders 14 known + materials and 24 known colors; arbitrary values won't draw an icon on the printer screen. +5. We **apply** them to the slot via the slot-config command (`msConfig_cmd`; see §5 — this command + is a prerequisite that must be backported first). +6. Show what matched ("Slot 2 → PLA · Red, from "), then refresh the station. + +Gate the whole affordance on **Spoolman being enabled/configured**. AD5X-only (IFS). + +--- + +## 2. Reference implementation (Android) + +Port the logic from these files in `flashforgeui-app`: + +- `app/src/main/java/me/ghost/ffui/ui/components/IfsPalette.kt` — the palette + `nearestColor` + (CIEDE2000) + `nearestMaterial`. **This is the core reusable piece.** +- `app/src/test/java/me/ghost/ffui/ui/components/IfsPaletteMatchingTest.kt` — the test cases to + re-port (all 24 swatches + live-Spoolman fixtures + material mappings). +- `app/src/main/java/me/ghost/ffui/ui/dashboard/SlotEditorSheet.kt` — the scan→resolve→match→apply + state machine (swap the NFC trigger for the spool picker). + +Authoritative wire/data reference: `flashforge-api-docs/docs-wiki/AD5X-IFS-Material-Station.md` +(the 24 colors, 14 materials, and the `msConfig_cmd` payload all come from here). + +--- + +## 3. Core logic to port: the palette + nearest-match + +Create one small, framework-agnostic module (pure TS, no DOM/Electron deps) — e.g. +`ifs-palette.ts`. It holds the fixed lists and two pure functions. + +### 3a. The fixed lists (exact — do not edit; from the API docs) + +**Materials (14):** +``` +PLA, PLA-CF, PETG, PETG-CF, ABS, TPU, SILK, PA, PA-CF, PAHT-CF, PC, PC-ABS, PET-CF, PPS-CF +``` + +**Colors (24) — name → hex:** +``` +White #FFFFFF Yellow #FEF043 Light Green #DCF478 Green #0ACC38 +Dark Green #067749 Teal #0C6283 Cyan #0DE2A0 Light Blue #75D9F3 +Blue #45A8F9 Dark Blue #2750E0 Purple #46328E Violet #A03CF7 +Magenta #F330F9 Pink #D4B0DC Coral #F95D73 Red #F72224 +Brown #7C4B00 Orange #F98D33 Cream #FDEBD5 Tan #D3C4A3 +Dark Brown #AF7836 Gray #898989 Light Gray #BCBCBC Black #161616 +``` + +### 3b. `nearestColor(hex) → PaletteColor | null` + +Snap an arbitrary `#RRGGBB` (also accept `RRGGBB` and `RRGGBBAA` — drop alpha) to the nearest of +the 24 swatches using **CIEDE2000** distance (not plain Euclidean Lab / ΔE76). + +> **Why CIEDE2000, not ΔE76:** ΔE76 mismatches the saturated blue/red regions. On the live Spoolman +> library it mapped pure blue `#0000FF` → **Violet** and burgundy `#951e23` → **Coral**. CIEDE2000 +> fixes both (`#0000FF` → **Dark Blue**, `#951e23` → **Red**). Verified against real data; the math +> is ~microseconds so there's no perf reason to cut the corner. + +Pipeline: parse hex → linearize sRGB → XYZ (D65) → CIELAB → CIEDE2000 vs each precomputed palette +Lab → argmin. Precompute the 24 palette Lab values once. Return `null` if the hex can't be parsed. +(Port the exact `hexToLab` + `ciede2000` from `IfsPalette.kt`; the Kotlin is plain `Math.*` and +translates 1:1 to TS `Math.*`.) + +### 3c. `nearestMaterial(raw) → string | null` + +1. **Exact** match on the whole normalized string (uppercase, strip non-alphanumerics) — so + `"PLA-CF"`, `"petg-cf"`, `"PLA+"` resolve to `PLA-CF` / `PETG-CF` / `PLA`. +2. Else match the **leading token** (before the first space): `"PLA Matte"` → `PLA`, + `"PETG-CF Pro"` → `PETG-CF`. +3. Else `null` → the caller **keeps the slot's current material**. + +> **Why leading-token, not longest-prefix:** a prefix rule wrongly snaps `"PCTG"` → `PC` and +> `"PA6"` → `PA` (chemically unrelated). Leading-token lets those fall through to `null` instead. + +--- + +## 4. Data plumbing (already present in this repo) + +- **Fetch one spool:** `SpoolmanService.getSpoolById(spoolId)` already exists. Use it (or the + already-loaded list item) to get `{ filament: { material, color_hex, multi_color_hexes, name } }`. + Prefer `color_hex`; fall back to the first of `multi_color_hexes`. If neither, surface an error + ("spool has no color in Spoolman"). +- **Spool list / picker UI:** the Spoolman feature already renders/selects spools (Electron: + `window.api.spoolman.openSpoolSelection()` in `ifs-station.ts`/`spoolman.ts`; WebUI: the + `spoolman` static feature + `spoolman-routes`). Reuse that selector as the picker rather than + building a new one. + +--- + +## 5. PREREQUISITE — the slot-config command must be backported first + +The apply step needs `msConfig_cmd` (set a slot's material + color). **It does not exist in +`ff-5mp-api-ts` yet** (verified: no `msConfig_cmd`/`ms_cmd` anywhere in this repo or the library). +It currently lives only in `ff-5mp-api-kt` (`FlashForgeHttpApi.configureSlot` / +`AD5XBackend.setSlotMaterial`). + +See **`ff-5mp-api-ts/docs/BACKPORT-FROM-KT.md`** — port `configureSlot` (msConfig_cmd) + `slotAction` +(ms_cmd) there first, then this feature can call it. Wire payload is in +`AD5X-IFS-Material-Station.md` (`POST /control`, `cmd: "msConfig_cmd"`, `args: { slot, mt, rgb }` +where `rgb` is hex **without** `#`). As a stopgap you could POST that `/control` body directly from +the app backend, but adding it to the library is the right home. + +--- + +## 6. Per-project placement + +**FlashForgeUI-Electron:** +- Palette/match util: `src/shared/` (pure, importable by main + renderer). +- Apply path: an IPC handler near `src/main/ipc/handlers/material-handlers.ts` calling the + (backported) slot-config command. +- UI: the "Set from Spoolman" button + picker wiring in + `src/renderer/src/ui/components/ifs-station/ifs-station.ts`. + +**FlashForgeWebUI:** +- Palette/match util: a static module under `src/webui/static/` (browser) — or `src/shared` if one + exists — imported by the IFS feature. +- Apply path: a server route near `src/webui/server/routes/spoolman-routes.ts` (or the material + route) calling the slot-config command. +- UI: extend the IFS rendering in `src/webui/static/features/` with the button + picker modal. + +--- + +## 7. Tests + +Port `IfsPaletteMatchingTest` to Jest: +- Each of the 24 swatches resolves to itself; a small (±6) neighborhood snaps back to it. +- Synthetic primaries (`#FF0000`→Red, `#0000FF`→**Dark Blue**, `#00FFFF`→Light Blue, …). +- Live-Spoolman fixtures (e.g. `#0000FF`→Dark Blue, `#951e23`→Red, `#6c4f4c`→Brown). +- `nearestMaterial`: `"PLA Matte"`→PLA, `"PETG-CF Pro"`→PETG-CF, `"PCTG"`/`"PA6"`/`"Nylon"`→null. + +--- + +## 8. Notes + +- Matching is **microseconds** — never the bottleneck. Any post-apply lag is network (Spoolman + fetch + printer write), so there's nothing to optimize in the math. +- Auto-apply vs. confirm-first is a UX call; Android auto-applies after the pick. A confirm step + (showing the snapped swatch before writing) is reasonable for a desktop/web picker. diff --git a/package-lock.json b/package-lock.json index f37e001..467679a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "@ghosttypes/ff-api": "^1.3.2", + "@ghosttypes/ff-api": "^1.6.1", "@parallel-7/slicer-meta": "1.1.0-20251121155836", "axios": "^1.8.4", "express": "^5.1.0", @@ -1222,9 +1222,9 @@ } }, "node_modules/@ghosttypes/ff-api": { - "version": "1.3.2", - "resolved": "https://npm.pkg.github.com/download/@ghosttypes/ff-api/1.3.2/8038c85264ee52e84beedb28dc401c568618f50e", - "integrity": "sha512-FRm+pQUik18tjxZmfCgy+rFQHTcoRQpfsK+Xtu1s+RU0YGzqLnx6TaIf3me18Gyif7FE07N8wKivyDKkQZf6Mg==", + "version": "1.6.1", + "resolved": "https://npm.pkg.github.com/download/@ghosttypes/ff-api/1.6.1/95687f4d27782db0a171c35a569aff8b93d1a916", + "integrity": "sha512-/WopgepJL0hMojkDkIxpUyd2SiPTaXo5wt6aZJFV7BzkXSnMEAqZmHIaI/DumfAfyvXkcQ0o3Q4iRhBKbULGAA==", "license": "ISC", "dependencies": { "axios": "^1.8.4", diff --git a/package.json b/package.json index c624887..9e7061c 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "author": "Parallel-7", "license": "MIT", "dependencies": { - "@ghosttypes/ff-api": "^1.3.2", + "@ghosttypes/ff-api": "^1.6.1", "@parallel-7/slicer-meta": "1.1.0-20251121155836", "axios": "^1.8.4", "express": "^5.1.0", diff --git a/src/managers/ConnectionFlowManager.ts b/src/managers/ConnectionFlowManager.ts index 958277e..aa09230 100644 --- a/src/managers/ConnectionFlowManager.ts +++ b/src/managers/ConnectionFlowManager.ts @@ -663,7 +663,8 @@ export class ConnectionFlowManager extends EventEmitter { tempResult.typeName, familyInfo.is5MFamily, checkCode, - forceLegacyMode + forceLegacyMode, + modelType ); if (!connectionResult) { @@ -945,7 +946,8 @@ export class ConnectionFlowManager extends EventEmitter { detailsWithDefaults.printerModel, familyInfo.is5MFamily, detailsWithDefaults.CheckCode, - detailsWithDefaults.forceLegacyMode ?? false + detailsWithDefaults.forceLegacyMode ?? false, + detectPrinterModelType(detailsWithDefaults.printerModel) ); if (!connectionResult) { diff --git a/src/managers/PrinterBackendManager.ts b/src/managers/PrinterBackendManager.ts index 990cb7e..1ca4250 100644 --- a/src/managers/PrinterBackendManager.ts +++ b/src/managers/PrinterBackendManager.ts @@ -28,6 +28,7 @@ import type { AD5XMaterialMapping, FiveMClient, FlashForgeClient } from '@ghosttypes/ff-api'; import { EventEmitter } from 'events'; import { AD5XBackend } from '../printer-backends/AD5XBackend'; +import { Creator5Backend } from '../printer-backends/Creator5Backend'; import { Adventurer5MBackend } from '../printer-backends/Adventurer5MBackend'; import { Adventurer5MProBackend } from '../printer-backends/Adventurer5MProBackend'; import type { BasePrinterBackend } from '../printer-backends/BasePrinterBackend'; @@ -289,6 +290,10 @@ export class PrinterBackendManager extends EventEmitter { case 'ad5x': return new AD5XBackend(backendOptions); + case 'creator-5': + case 'creator-5-pro': + return new Creator5Backend(backendOptions); + default: // Fallback to generic legacy for unknown models console.warn(`Unknown printer model: ${modelType}, falling back to generic legacy backend`); diff --git a/src/printer-backends/Creator5Backend.ts b/src/printer-backends/Creator5Backend.ts new file mode 100644 index 0000000..cde53ff --- /dev/null +++ b/src/printer-backends/Creator5Backend.ts @@ -0,0 +1,273 @@ +/** + * @fileoverview Backend implementation for Creator 5 / Creator 5 Pro printers. + * + * The Creator 5 series is a material-station printer like the AD5X, but differs in + * several wire-level ways, so it extends {@link AD5XBackend} and overrides only the + * Creator 5 specifics: + * + * - **HTTP-only**: no legacy TCP server (port 8899). The FiveMClient runs in + * `httpOnly` mode and is the entire connection; there is no secondary client and + * no raw G-code / M-code passthrough. + * - **Two-step material workflow**: unlike the AD5X (which maps materials at upload + * time), the Creator 5 uploads the file with `useMatlStation` / `gcodeToolCnt` + * flags and then maps per-tool materials at print-start via `POST /printGcode`. + * - **Per-tool temperatures**: a 4-nozzle tool changer plus a heated chamber. + * - **Camera** on both Creator 5 and Creator 5 Pro; **filtration / door sensor** on + * the Pro only (though its filtration controls are not exposed — only a TVOC read). + * + * Key exports: + * - Creator5Backend class: Backend for Creator 5 / Creator 5 Pro printers + */ + +import { type Creator5MaterialMapping, FiveMClient, type FFMachineInfo } from '@ghosttypes/ff-api'; +import * as path from 'path'; +import type { + JobOperationParams, + JobStartResult, + MaterialStationStatus, + PrinterFeatureSet, +} from '../types/printer-backend'; +import { AD5XBackend } from './AD5XBackend'; + +/** + * Backend implementation for the Creator 5 / Creator 5 Pro. Extends the AD5X + * material-station backend, overriding the Creator 5's HTTP-only transport, its + * two-step material-mapping print flow, per-tool temperatures, and Pro hardware. + */ +export class Creator5Backend extends AD5XBackend { + /** + * HTTP-only client initialization. The Creator 5 has no TCP channel, so we + * validate only the primary FiveMClient and leave the legacy client unset (the + * base {@link DualAPIBackend.initializeClients} would require a secondary client). + */ + protected initializeClients(): void { + if (!(this.primaryClient instanceof FiveMClient)) { + throw new Error('Creator5Backend requires FiveMClient as primary client'); + } + this.fiveMClient = this.primaryClient; + // No secondaryClient / legacyClient: the Creator 5 series is HTTP-only. + } + + /** Whether this specific printer is a Creator 5 Pro (vs a plain Creator 5). */ + private isCreator5Pro(): boolean { + return this.modelType === 'creator-5-pro'; + } + + /** + * Creator 5 base features: the AD5X material-station set, but HTTP-only — no raw + * G-code passthrough and no legacy status path. The Creator 5 Pro ships with + * filtration hardware, but its filtration controls are not exposed (only a + * read-only TVOC value), so filtration stays non-controllable here. + */ + protected getChildBaseFeatures(): PrinterFeatureSet { + const features = super.getChildBaseFeatures(); + + return { + ...features, + camera: { + ...features.camera, + }, + ledControl: { + ...features.ledControl, + // Creator 5 has a built-in LED; no legacy TCP path. + builtin: true, + usesLegacyAPI: false, + }, + filtration: { + available: false, + controllable: false, + reason: 'Filtration control is not available on the Creator 5 series', + }, + // The Creator 5 series is HTTP-only and exposes NO raw G-code / M-code + // passthrough: the firmware's only command surface is the HTTP /control set. + gcodeCommands: { + available: false, + usesLegacyAPI: false, + supportedCommands: [], + }, + statusMonitoring: { + ...features.statusMonitoring, + usesNewAPI: true, + usesLegacyAPI: false, + }, + jobManagement: { + ...features.jobManagement, + usesNewAPI: true, + }, + }; + } + + /** + * Surface Creator 5-specific status fields (per-tool temps, chamber, air quality, + * door capability) on top of the AD5X status. + */ + protected getAdditionalStatusFields(machineInfo: unknown): Record { + const base = super.getAdditionalStatusFields(machineInfo); + const info = machineInfo as Partial | null; + + return { + ...base, + // Per-tool current/target temperatures (4 entries on the Creator 5 series). + toolTemps: info?.ToolTemps ?? [], + // Chamber temperature (both Creator 5 and 5 Pro have a heated chamber). + chamberTemp: info?.Chamber?.current ?? 0, + chamberTargetTemp: info?.Chamber?.set ?? 0, + // Air-quality reading (Creator 5 Pro). + tvoc: info?.Tvoc ?? 0, + // Capability flags for the renderer to gate UI. + isCreator5Pro: this.isCreator5Pro(), + hasChamberControl: true, // Creator 5 / 5 Pro always have a heated chamber + hasDoorSensor: info?.HasDoorSensor ?? this.isCreator5Pro(), + }; + } + + /** + * Material-station status, tagged with the printer model so the renderer picks + * the Creator 5 palette (rather than the default AD5X palette). + */ + public getMaterialStationStatus(): MaterialStationStatus | null { + const status = super.getMaterialStationStatus(); + return status ? { ...status, printerModelType: this.modelType } : null; + } + + /** + * Start a job on the Creator 5 / Creator 5 Pro. + * + * A fresh file upload goes through the two-step material flow + * ({@link uploadCreator5File}); an already-resident file goes straight to the + * native print-start command (`POST /printGcode`) with the per-tool mappings. + */ + public async startJob(params: JobOperationParams): Promise { + try { + const materialMappings = params.additionalParams?.materialMappings as + | Creator5MaterialMapping[] + | undefined; + + // Fresh file upload: upload (with the material-station flags), then start. + if (params.filePath) { + return await this.uploadCreator5File( + params.filePath, + params.startNow, + params.leveling, + materialMappings, + params.fileName + ); + } + + if (!params.fileName) { + throw new Error('fileName or filePath is required'); + } + + // Upload-only request (start handled separately by the caller). + if (!params.startNow) { + return { + success: true, + fileName: params.fileName, + started: false, + timestamp: new Date(), + }; + } + + const started = await this.startCreator5Print(params.fileName, params.leveling, materialMappings); + if (!started) { + throw new Error('Failed to start Creator 5 job'); + } + + return { + success: true, + fileName: params.fileName, + started: true, + timestamp: new Date(), + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + fileName: params.fileName || '', + started: false, + timestamp: new Date(), + }; + } + } + + /** + * Material-station file upload for the Creator 5. Overrides the AD5X method name + * but runs the Creator 5 two-step flow. Material matching is always used on the + * Creator 5, so `useMatlStation` is always true here. + */ + public async uploadFileAD5X( + filePath: string, + startPrint: boolean, + levelingBeforePrint: boolean, + materialMappings?: Creator5MaterialMapping[] + ): Promise { + return this.uploadCreator5File(filePath, startPrint, levelingBeforePrint, materialMappings); + } + + /** + * The Creator 5 two-step upload: upload the file (never auto-start via `printNow`, + * since the material mapping is applied by the follow-up `/printGcode`), then — + * when requested — start the print with the mappings. + */ + private async uploadCreator5File( + filePath: string, + startPrint: boolean, + levelingBeforePrint: boolean, + materialMappings?: Creator5MaterialMapping[], + fileNameOverride?: string + ): Promise { + const fileName = fileNameOverride || path.basename(filePath); + const toolCount = materialMappings && materialMappings.length > 0 ? materialMappings.length : 1; + + try { + const uploaded = await this.fiveMClient.jobControl.uploadFileCreator5({ + filePath, + startPrint: false, + levelingBeforePrint, + useMatlStation: true, + gcodeToolCnt: toolCount, + }); + if (!uploaded) { + throw new Error('Failed to upload job to Creator 5'); + } + + if (startPrint) { + const started = await this.startCreator5Print(fileName, levelingBeforePrint, materialMappings); + if (!started) { + throw new Error('Failed to start Creator 5 job after upload'); + } + } + + return { + success: true, + fileName, + started: startPrint, + timestamp: new Date(), + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + fileName, + started: false, + timestamp: new Date(), + }; + } + } + + /** + * Issue the Creator 5 native print-start (`POST /printGcode`) for a file already + * on the printer, with optional per-tool material mappings. + */ + private async startCreator5Print( + fileName: string, + levelingBeforePrint: boolean, + materialMappings?: Creator5MaterialMapping[] + ): Promise { + return await this.fiveMClient.jobControl.startCreator5Job({ + fileName, + levelingBeforePrint, + materialMappings: materialMappings && materialMappings.length > 0 ? materialMappings : undefined, + }); + } +} diff --git a/src/services/ConnectionEstablishmentService.ts b/src/services/ConnectionEstablishmentService.ts index 499277a..cedc868 100644 --- a/src/services/ConnectionEstablishmentService.ts +++ b/src/services/ConnectionEstablishmentService.ts @@ -18,17 +18,21 @@ * conjunction with ConnectionFlowManager for complete connection workflows. */ -import { FiveMClient, FlashForgeClient } from '@ghosttypes/ff-api'; +import { FiveMClient, type FiveMClientConnectionOptions, FlashForgeClient } from '@ghosttypes/ff-api'; import { EventEmitter } from 'events'; import type { DiscoveredPrinter, ExtendedPrinterInfo, + PrinterModelType, TemporaryConnectionResult, } from '../types/printer'; import { detectPrinterFamily, detectPrinterModelType, + detectPrinterModelTypeFromId, getConnectionErrorMessage, + getModelDisplayName, + isHttpOnlyModel, } from '../utils/PrinterUtils'; interface PortMutableFlashForgeClient { @@ -78,7 +82,19 @@ export class ConnectionEstablishmentService extends EventEmitter { return client; } - private createFiveMClient(printer: DiscoveredPrinter, checkCode: string): FiveMClient { + private createFiveMClient(printer: DiscoveredPrinter, checkCode: string, httpOnly = false): FiveMClient { + // HTTP-only models (Creator 5 / 5 Pro) have no TCP channel. Pass the option so the + // client never opens the TCP socket, and skip the port-mutation path (which would + // otherwise try to connect a TCP socket that doesn't exist). + if (httpOnly) { + const options: FiveMClientConnectionOptions = { + httpPort: printer.eventPort, + tcpPort: printer.commandPort, + httpOnly: true, + }; + return new FiveMClient(printer.ipAddress, printer.serialNumber, checkCode, options); + } + const client = new FiveMClient(printer.ipAddress, printer.serialNumber, checkCode); const mutableClient = client as unknown as PortMutableFiveMClient; @@ -117,6 +133,25 @@ export class ConnectionEstablishmentService extends EventEmitter { ): Promise { this.emit('temporary-connection-started', printer); + // HTTP-only models (Creator 5 / 5 Pro) run no legacy TCP server, so the usual + // TCP probe can't work. When discovery's USB product ID identifies such a model, + // synthesize the type info from the discovery packet and skip the TCP probe. + const idModelType = detectPrinterModelTypeFromId(printer.productId, ''); + if (isHttpOnlyModel(idModelType)) { + const typeName = getModelDisplayName(idModelType); + console.log(`[Connection] HTTP-only model detected via product ID: ${typeName}`); + this.emit('printer-type-detected', { typeName, familyInfo: detectPrinterFamily(typeName) }); + return { + success: true, + typeName, + printerInfo: { + Name: printer.name, + SerialNumber: printer.serialNumber, + TypeName: typeName, + } as unknown as ExtendedPrinterInfo, + }; + } + for (let attempt = 1; attempt <= retries; attempt++) { let tempClient: FlashForgeClient | null = null; @@ -288,13 +323,33 @@ export class ConnectionEstablishmentService extends EventEmitter { typeName: string, is5MFamily: boolean, checkCode: string, - forceLegacyMode: boolean + forceLegacyMode: boolean, + modelType?: PrinterModelType ): Promise { this.emit('final-connection-started', { printer, typeName }); try { + // Resolve the model first, falling back to the typeName for manual connections + // that never resolved a product ID. + const resolvedModelType = + modelType ?? detectPrinterModelTypeFromId(printer.productId, typeName); + const httpOnly = isHttpOnlyModel(resolvedModelType); + + // HTTP-only models (Creator 5 / 5 Pro) have NO legacy TCP server, so they can + // never use the legacy path. A stale forceLegacyMode flag or a missed 5M-family + // detection would otherwise route them to establishLegacyConnection and hang on + // port 8899. For these models HTTP always wins, regardless of forceLegacyMode. + if (httpOnly) { + if (forceLegacyMode) { + console.warn( + `Ignoring forceLegacyMode for HTTP-only model ${resolvedModelType}: it has no legacy TCP server` + ); + } + return await this.establishDualAPIConnection(printer, typeName, checkCode, true); + } + if (is5MFamily && !forceLegacyMode) { - return await this.establishDualAPIConnection(printer, typeName, checkCode); + return await this.establishDualAPIConnection(printer, typeName, checkCode, false); } else { return await this.establishLegacyConnection(printer); } @@ -306,19 +361,22 @@ export class ConnectionEstablishmentService extends EventEmitter { } /** - * Establish dual API connection for 5M family printers + * Establish dual API connection for 5M family printers. HTTP-only models (Creator 5 + * series) skip the secondary legacy client — the HTTP client is the whole connection. */ private async establishDualAPIConnection( printer: DiscoveredPrinter, typeName: string, - checkCode: string + checkCode: string, + httpOnly = false ): Promise { - console.log('Creating dual API connection for 5M family printer'); + console.log(`Creating ${httpOnly ? 'HTTP-only' : 'dual'} API connection for 5M family printer`); console.log('Connection details:', { ipAddress: printer.ipAddress, serialNumber: printer.serialNumber, name: printer.name, hasValidSerial: !!(printer.serialNumber && printer.serialNumber.trim() !== ''), + httpOnly, }); // Validate that we have a valid serial number for FiveMClient @@ -328,7 +386,7 @@ export class ConnectionEstablishmentService extends EventEmitter { } // Primary client: FiveMClient for new API operations - const primaryClient = this.createFiveMClient(printer, checkCode); + const primaryClient = this.createFiveMClient(printer, checkCode, httpOnly); const mutablePrimaryClient = primaryClient as unknown as ModelMutableFiveMClient; try { @@ -352,6 +410,17 @@ export class ConnectionEstablishmentService extends EventEmitter { } console.log('FiveMClient control initialized successfully'); + // HTTP-only models (Creator 5 / 5 Pro) have no legacy TCP server — there is no + // secondary client to create. The HTTP client is the whole connection. + if (httpOnly) { + console.log('HTTP-only connection established (no secondary TCP client)'); + this.emit('dual-api-connection-established', { + ipAddress: printer.ipAddress, + serialNumber: printer.serialNumber, + }); + return { primaryClient }; + } + // Add a small delay to ensure primary client is fully ready await new Promise((resolve) => setTimeout(resolve, 500)); diff --git a/src/services/PrinterDataTransformer.test.ts b/src/services/PrinterDataTransformer.test.ts new file mode 100644 index 0000000..3b52258 --- /dev/null +++ b/src/services/PrinterDataTransformer.test.ts @@ -0,0 +1,69 @@ +/** + * @fileoverview Tests for PrinterDataTransformer Creator 5 multi-tool normalization. + * + * Regression guard for the tool/chamber temperature mapping: `Creator5Backend` + * surfaces raw ff-api `Temperature` entries shaped `{ current, set }` (NOT + * `{ current, target }`), so the transformer must read `set` for the per-tool + * target. Reading `target` silently defaulted every nozzle target to 0. + */ + +import { afterAll, describe, expect, it, jest } from '@jest/globals'; + +const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); +const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + +import { printerDataTransformer } from './PrinterDataTransformer'; + +describe('PrinterDataTransformer — Creator 5 multi-tool', () => { + afterAll(() => { + consoleLogSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + }); + + it('maps per-tool target temperatures from the ff-api `set` key', () => { + const status = printerDataTransformer.transformPrinterStatus({ + printerState: 'printing', + bedTemperature: 60, + bedTargetTemperature: 65, + // Raw ff-api ToolTemps: current/`set` pairs, one per nozzle. + toolTemps: [ + { current: 230, set: 235 }, + { current: 24, set: 0 }, + { current: 200, set: 200 }, + { current: 25, set: 0 }, + ], + hasChamberControl: true, + chamberTemp: 40, + chamberTargetTemp: 50, + }); + + expect(status).not.toBeNull(); + // Targets must come from `set`, not a non-existent `target` key (which would be 0). + expect(status?.toolTemps).toEqual([ + { current: 230, target: 235, isHeating: true }, + { current: 24, target: 0, isHeating: false }, + { current: 200, target: 200, isHeating: false }, + { current: 25, target: 0, isHeating: false }, + ]); + // Chamber uses `set` semantics too (via chamberTargetTemp) and surfaces on temperatures. + expect(status?.temperatures.chamber).toEqual({ + current: 40, + target: 50, + isHeating: true, + }); + }); + + it('omits toolTemps and chamber for single-nozzle printers', () => { + const status = printerDataTransformer.transformPrinterStatus({ + printerState: 'ready', + bedTemperature: 25, + bedTargetTemperature: 0, + nozzleTemperature: 26, + nozzleTargetTemperature: 0, + }); + + expect(status).not.toBeNull(); + expect(status?.toolTemps).toBeUndefined(); + expect(status?.temperatures.chamber).toBeUndefined(); + }); +}); diff --git a/src/services/PrinterDataTransformer.ts b/src/services/PrinterDataTransformer.ts index feea16a..537585d 100644 --- a/src/services/PrinterDataTransformer.ts +++ b/src/services/PrinterDataTransformer.ts @@ -95,6 +95,18 @@ export class PrinterDataTransformer { const tvoc = safeExtractNumber(backendData, 'tvoc', 0); const filtrationInfo = this.extractFiltrationStatus(backendData); + // Creator 5 multi-tool + chamber temperatures (present only on Creator 5 backends). + // Raw ff-api Temperature entries use `set` (not `target`) for the target reading. + const rawToolTemps = safeExtractArray(backendData, 'toolTemps', []); + const toolTemps = rawToolTemps.filter(isValidObject).map((tool) => { + const current = safeExtractNumber(tool, 'current', 0); + const target = safeExtractNumber(tool, 'set', 0); + return { current, target, isHeating: target > 0 && Math.abs(current - target) > 2 }; + }); + const hasChamberControl = safeExtractBoolean(backendData, 'hasChamberControl', false); + const chamberTemp = safeExtractNumber(backendData, 'chamberTemp', 0); + const chamberTargetTemp = safeExtractNumber(backendData, 'chamberTargetTemp', 0); + // Extract cumulative stats const cumulativePrintTime = safeExtractNumber(backendData, 'cumulativePrintTime', 0); const cumulativeFilament = safeExtractNumber(backendData, 'cumulativeFilament', 0); @@ -112,7 +124,17 @@ export class PrinterDataTransformer { target: nozzleTarget, isHeating: nozzleTarget > 0 && Math.abs(nozzleTemp - nozzleTarget) > 2, }, + ...(hasChamberControl + ? { + chamber: { + current: chamberTemp, + target: chamberTargetTemp, + isHeating: chamberTargetTemp > 0 && Math.abs(chamberTemp - chamberTargetTemp) > 2, + }, + } + : {}), }, + ...(toolTemps.length > 0 ? { toolTemps } : {}), fans: { coolingFan: coolingFanSpeed, chamberFan: chamberFanSpeed, diff --git a/src/services/PrinterDiscoveryService.ts b/src/services/PrinterDiscoveryService.ts index 0a48c02..9e601a0 100644 --- a/src/services/PrinterDiscoveryService.ts +++ b/src/services/PrinterDiscoveryService.ts @@ -264,6 +264,9 @@ export class PrinterDiscoveryService extends EventEmitter { serialNumber, commandPort: response.readUInt16BE(0x84), eventPort: response.readUInt16BE(0x8e), + // USB product ID identifies the model authoritatively (e.g. Creator 5 = 0x0028), + // which is essential for HTTP-only models that can't be TCP-probed. + productId: response.readUInt16BE(0x88), model: 'Unknown', status: 'Discovered', }; diff --git a/src/types/polling.ts b/src/types/polling.ts index 74d00d4..0addf5f 100644 --- a/src/types/polling.ts +++ b/src/types/polling.ts @@ -146,6 +146,12 @@ export interface CumulativeStats { export interface PrinterStatus { state: PrinterState; temperatures: PrinterTemperatures; + /** + * Per-tool nozzle temperatures for multi-tool printers (Creator 5 series, one + * entry per nozzle). Undefined/empty on single-nozzle printers, which use + * {@link PrinterTemperatures.extruder} instead. + */ + toolTemps?: TemperatureData[]; fans: FanStatus; filtration: FiltrationStatus; settings: PrinterSettings; diff --git a/src/types/printer-backend/backend-operations.ts b/src/types/printer-backend/backend-operations.ts index f4bf2cb..6a06d61 100644 --- a/src/types/printer-backend/backend-operations.ts +++ b/src/types/printer-backend/backend-operations.ts @@ -20,7 +20,13 @@ import type { MaterialStationStatus, PrinterFeatureSet } from './printer-feature /** * Printer model types supported by the backend system */ -export type PrinterModelType = 'generic-legacy' | 'adventurer-5m' | 'adventurer-5m-pro' | 'ad5x'; +export type PrinterModelType = + | 'generic-legacy' + | 'adventurer-5m' + | 'adventurer-5m-pro' + | 'ad5x' + | 'creator-5' + | 'creator-5-pro'; /** * Backend initialization options diff --git a/src/types/printer-backend/printer-features.ts b/src/types/printer-backend/printer-features.ts index db23ebd..c134f62 100644 --- a/src/types/printer-backend/printer-features.ts +++ b/src/types/printer-backend/printer-features.ts @@ -14,6 +14,8 @@ * - FeatureDisableReason: User-facing explanations for unavailable features */ +import type { PrinterModelType } from './backend-operations'; + /** * Printer feature types that can be available on different printer models */ @@ -148,6 +150,11 @@ export interface MaterialStationStatus { readonly activeSlot: number | null; readonly overallStatus: 'ready' | 'warming' | 'error' | 'disconnected'; readonly errorMessage: string | null; + /** + * Printer model that owns this station, so the slot editor picks the correct + * fixed palette (AD5X vs Creator 5). Optional/absent for AD5X payloads. + */ + readonly printerModelType?: PrinterModelType; } /** diff --git a/src/types/printer.ts b/src/types/printer.ts index 8911780..030bd1a 100644 --- a/src/types/printer.ts +++ b/src/types/printer.ts @@ -15,7 +15,13 @@ /** * Printer model types supported by the backend system */ -export type PrinterModelType = 'generic-legacy' | 'adventurer-5m' | 'adventurer-5m-pro' | 'ad5x'; +export type PrinterModelType = + | 'generic-legacy' + | 'adventurer-5m' + | 'adventurer-5m-pro' + | 'ad5x' + | 'creator-5' + | 'creator-5-pro'; /** * Client type for printer connection @@ -66,6 +72,8 @@ export interface DiscoveredPrinter { readonly firmwareVersion?: string; readonly commandPort?: number; readonly eventPort?: number; + /** USB product ID from the discovery packet (offset 0x88); authoritative for model selection. */ + readonly productId?: number; } /** diff --git a/src/utils/PrinterUtils.ts b/src/utils/PrinterUtils.ts index c84338a..a9a13b3 100644 --- a/src/utils/PrinterUtils.ts +++ b/src/utils/PrinterUtils.ts @@ -81,12 +81,73 @@ export const detectPrinterModelType = (typeName: string): PrinterModelType => { return 'adventurer-5m'; } else if (typeNameLower.includes('ad5x')) { return 'ad5x'; + } else if (typeNameLower.includes('creator 5 pro')) { + return 'creator-5-pro'; + } else if (typeNameLower.includes('creator 5')) { + return 'creator-5'; } // Default to generic legacy for all other printers return 'generic-legacy'; }; +/** + * Models that speak HTTP only (no legacy TCP server on port 8899). The Creator 5 + * series has no TCP channel, so type detection and control must go over HTTP. + */ +const HTTP_ONLY_MODEL_TYPES: ReadonlySet = new Set(['creator-5', 'creator-5-pro']); + +/** Whether a model runs HTTP-only (no legacy TCP server). */ +export const isHttpOnlyModel = (modelType: PrinterModelType): boolean => + HTTP_ONLY_MODEL_TYPES.has(modelType); + +/** + * USB product IDs for new-API (HTTP + check-code) printers. The firmware-set + * product ID is authoritative for model selection; typeName is the fallback. + * Keys are decimal (the discovery packet is read via `readUInt16BE`); the hex + * value each corresponds to is noted alongside. + */ +export const NEW_API_PRODUCT_IDS: Readonly> = { + 35: 'adventurer-5m', // 0x0023 + 36: 'adventurer-5m-pro', // 0x0024 + 38: 'ad5x', // 0x0026 + 40: 'creator-5', // 0x0028 + 41: 'creator-5-pro', // 0x0029 +}; + +/** + * Detect the model preferring the authoritative USB product ID, falling back to + * the typeName when no product ID is available. + */ +export const detectPrinterModelTypeFromId = ( + productId: number | undefined, + typeName: string +): PrinterModelType => { + if (productId !== undefined && productId in NEW_API_PRODUCT_IDS) { + return NEW_API_PRODUCT_IDS[productId]; + } + return detectPrinterModelType(typeName); +}; + +/** + * Detect printer family preferring the authoritative USB product ID, falling back + * to the typeName. Any printer in {@link NEW_API_PRODUCT_IDS} is a new-API printer + * that requires a check code. + */ +export const detectPrinterFamilyFromId = ( + productId: number | undefined, + typeName: string +): PrinterFamilyInfo => { + if (productId !== undefined && productId in NEW_API_PRODUCT_IDS) { + return { + is5MFamily: true, + requiresCheckCode: true, + familyName: getModelDisplayName(NEW_API_PRODUCT_IDS[productId]), + }; + } + return detectPrinterFamily(typeName); +}; + /** * Get detailed printer model information * Includes feature capabilities and requirements @@ -130,6 +191,30 @@ export const getPrinterModelInfo = (typeName: string): EnhancedPrinterFamilyInfo hasBuiltinFiltration: false, supportsMaterialStation: true, }; + + case 'creator-5': + return { + is5MFamily: true, + requiresCheckCode: true, + familyName: 'Creator 5', + modelType, + hasBuiltinCamera: true, + hasBuiltinLED: true, + hasBuiltinFiltration: false, + supportsMaterialStation: true, + }; + + case 'creator-5-pro': + return { + is5MFamily: true, + requiresCheckCode: true, + familyName: 'Creator 5 Pro', + modelType, + hasBuiltinCamera: true, + hasBuiltinLED: true, + hasBuiltinFiltration: true, + supportsMaterialStation: true, + }; default: return { is5MFamily: false, @@ -163,6 +248,10 @@ export const getModelDisplayName = (modelType: PrinterModelType): string => { return 'Adventurer 5M'; case 'ad5x': return 'AD5X'; + case 'creator-5': + return 'Creator 5'; + case 'creator-5-pro': + return 'Creator 5 Pro'; default: return 'Legacy Printer'; } @@ -173,7 +262,7 @@ export const getModelDisplayName = (modelType: PrinterModelType): string => { * Currently only AD5X has material station support */ export const requiresMaterialStation = (modelType: PrinterModelType): boolean => { - return modelType === 'ad5x'; + return modelType === 'ad5x' || modelType === 'creator-5' || modelType === 'creator-5-pro'; }; /** @@ -230,8 +319,12 @@ export const detectPrinterFamily = (typeName: string): PrinterFamilyInfo => { const typeNameLower = typeName.toLowerCase(); - // Check for 5M family indicators - const is5MFamily = typeNameLower.includes('5m') || typeNameLower.includes('ad5x'); + // Check for new-API ("5M family") indicators. Creator 5 / 5 Pro speak the same + // HTTP + check-code protocol, so they belong here too. + const is5MFamily = + typeNameLower.includes('5m') || + typeNameLower.includes('ad5x') || + typeNameLower.includes('creator 5'); if (is5MFamily) { let familyName = 'Adventurer 5M Family'; @@ -242,6 +335,10 @@ export const detectPrinterFamily = (typeName: string): PrinterFamilyInfo => { familyName = 'Adventurer 5M'; } else if (typeNameLower.includes('ad5x')) { familyName = 'AD5X'; + } else if (typeNameLower.includes('creator 5 pro')) { + familyName = 'Creator 5 Pro'; + } else if (typeNameLower.includes('creator 5')) { + familyName = 'Creator 5'; } return { diff --git a/src/webui/server/WebSocketManager.ts b/src/webui/server/WebSocketManager.ts index b55fbf4..ab1bc64 100644 --- a/src/webui/server/WebSocketManager.ts +++ b/src/webui/server/WebSocketManager.ts @@ -493,6 +493,13 @@ export class WebSocketManager extends EventEmitter { ? rawFiltrationMode : 'none'; + // Creator 5 series (multi-tool) fields. Undefined/empty on single-nozzle printers. + const toolTemps = (status.toolTemps ?? []).map((tool) => ({ + current: Math.round(tool.current), + target: Math.round(tool.target), + })); + const chamber = status.temperatures?.chamber; + return { printerState: status.state, // Note: 'state' not 'printerState' bedTemperature: Math.round(bedTemp.current), @@ -523,6 +530,12 @@ export class WebSocketManager extends EventEmitter { // Backend provides filament usage in meters, same as main UI cumulativeFilament: status.cumulativeStats?.totalFilamentUsed || undefined, cumulativePrintTime: status.cumulativeStats?.totalPrintTime || undefined, + // Creator 5 multi-tool + chamber + air-quality fields + toolTemps: toolTemps.length > 0 ? toolTemps : undefined, + chamberTemperature: chamber ? Math.round(chamber.current) : undefined, + chamberTargetTemperature: chamber ? Math.round(chamber.target) : undefined, + hasChamberControl: chamber !== undefined ? true : undefined, + tvocLevel: status.filtration?.tvocLevel, }; } diff --git a/src/webui/server/routes/printer-status-routes.ts b/src/webui/server/routes/printer-status-routes.ts index 44f9bf7..eb11c30 100644 --- a/src/webui/server/routes/printer-status-routes.ts +++ b/src/webui/server/routes/printer-status-routes.ts @@ -45,6 +45,14 @@ interface ExtendedPrinterStatus { readonly cumulativeFilament?: number; readonly cumulativePrintTime?: number; readonly printEta?: string; + // Creator 5 series (multi-tool) fields surfaced by Creator5Backend.getAdditionalStatusFields. + // Raw ff-api Temperature entries use `set` (not `target`) for the target reading. + readonly toolTemps?: ReadonlyArray<{ readonly current: number; readonly set: number }>; + readonly chamberTemp?: number; + readonly chamberTargetTemp?: number; + readonly hasChamberControl?: boolean; + readonly isCreator5Pro?: boolean; + readonly tvoc?: number; } export function registerPrinterStatusRoutes(router: Router, deps: RouteDependencies): void { @@ -80,6 +88,12 @@ export function registerPrinterStatusRoutes(router: Router, deps: RouteDependenc let formattedEta: string | undefined; let cumulativeFilament: number | undefined; let cumulativePrintTime: number | undefined; + let toolTemps: Array<{ current: number; target: number }> | undefined; + let chamberTemperature: number | undefined; + let chamberTargetTemperature: number | undefined; + let hasChamberControl: boolean | undefined; + let isCreator5Pro: boolean | undefined; + let tvocLevel: number | undefined; if (isExtendedPrinterStatus(statusResult.status)) { bedTargetTemp = @@ -109,6 +123,21 @@ export function registerPrinterStatusRoutes(router: Router, deps: RouteDependenc if ('cumulativePrintTime' in statusResult.status) { cumulativePrintTime = statusResult.status.cumulativePrintTime as number; } + + // Creator 5 series (multi-tool) fields — present only on Creator5Backend. + if (statusResult.status.toolTemps && statusResult.status.toolTemps.length > 0) { + toolTemps = statusResult.status.toolTemps.map((tool) => ({ + current: Math.round(tool.current), + target: Math.round(tool.set), + })); + } + if (statusResult.status.hasChamberControl) { + hasChamberControl = true; + chamberTemperature = Math.round(statusResult.status.chamberTemp ?? 0); + chamberTargetTemperature = Math.round(statusResult.status.chamberTargetTemp ?? 0); + } + isCreator5Pro = statusResult.status.isCreator5Pro; + tvocLevel = statusResult.status.tvoc; } const response: PrinterStatusResponse = { @@ -132,6 +161,12 @@ export function registerPrinterStatusRoutes(router: Router, deps: RouteDependenc cumulativePrintTime, formattedEta, elapsedTimeSeconds, + toolTemps, + chamberTemperature, + chamberTargetTemperature, + hasChamberControl, + isCreator5Pro, + tvocLevel, }, }; @@ -160,6 +195,11 @@ export function registerPrinterStatusRoutes(router: Router, deps: RouteDependenc return sendErrorResponse(res, 500, 'Failed to get printer features'); } + // Creator 5 series capability flags derive from the backend model type. + const modelType = deps.backendManager.getBackendStatus(contextId)?.capabilities.modelType; + const isCreator5Pro = modelType === 'creator-5-pro'; + const hasMultiTool = modelType === 'creator-5' || modelType === 'creator-5-pro'; + const featureResponse: PrinterFeatures = { hasCamera: deps.backendManager.isFeatureAvailable(contextId, 'camera'), hasLED: deps.backendManager.isFeatureAvailable(contextId, 'led-control'), @@ -170,6 +210,8 @@ export function registerPrinterStatusRoutes(router: Router, deps: RouteDependenc canCancel: features.jobManagement.cancelJobs, ledUsesLegacyAPI: features.ledControl.customControlEnabled || features.ledControl.usesLegacyAPI, + hasMultiTool, + isCreator5Pro, }; return res.json({ diff --git a/src/webui/server/routes/temperature-routes.ts b/src/webui/server/routes/temperature-routes.ts index 29027b2..f2c6bf8 100644 --- a/src/webui/server/routes/temperature-routes.ts +++ b/src/webui/server/routes/temperature-routes.ts @@ -2,6 +2,7 @@ * @fileoverview Temperature control API routes for the WebUI server. */ +import { FiveMClient } from '@ghosttypes/ff-api'; import type { Response, Router } from 'express'; import { toAppError } from '../../../utils/error.utils'; import { createValidationError, TemperatureSetRequestSchema } from '../../schemas/web-api.schemas'; @@ -9,6 +10,12 @@ import type { StandardAPIResponse } from '../../types/web-api.types'; import type { AuthenticatedRequest } from '../auth-middleware'; import { type RouteDependencies, resolveContext, sendErrorResponse } from './route-helpers'; +/** Firmware chamber temperature ceiling (matches the desktop Creator 5 card). */ +const CHAMBER_MAX_TEMP = 80; + +/** Creator 5 series wire format is a fixed 4-nozzle array; valid tool indices are 0-3. */ +const MAX_TOOL_COUNT = 4; + export function registerTemperatureRoutes(router: Router, deps: RouteDependencies): void { router.post('/printer/temperature/bed', async (req: AuthenticatedRequest, res: Response) => { try { @@ -96,6 +103,83 @@ export function registerTemperatureRoutes(router: Router, deps: RouteDependencie ); } ); + + // ----- Creator 5 series (multi-tool) heaters ------------------------------- + // These route through the FiveMClient temperature control API rather than raw + // G-code, since the Creator 5 series is HTTP-only with no G-code passthrough. + + router.post('/printer/temperature/tool/:index', async (req: AuthenticatedRequest, res: Response) => { + const toolIndex = Number.parseInt(req.params.index, 10); + if (!Number.isInteger(toolIndex) || toolIndex < 0 || toolIndex >= MAX_TOOL_COUNT) { + return sendErrorResponse(res, 400, 'Invalid tool index'); + } + const validation = TemperatureSetRequestSchema.safeParse(req.body); + if (!validation.success) { + return sendErrorResponse(res, 400, createValidationError(validation.error).error); + } + const temperature = Math.round(validation.data.temperature); + return handleTempControl(req, res, deps, (client) => client.tempControl.setToolTemp(toolIndex, temperature), `Setting tool T${toolIndex + 1} temperature to ${temperature}°C`); + }); + + router.post('/printer/temperature/tool/:index/off', async (req: AuthenticatedRequest, res: Response) => { + const toolIndex = Number.parseInt(req.params.index, 10); + if (!Number.isInteger(toolIndex) || toolIndex < 0 || toolIndex >= MAX_TOOL_COUNT) { + return sendErrorResponse(res, 400, 'Invalid tool index'); + } + return handleTempControl(req, res, deps, (client) => client.tempControl.cancelToolTemp(toolIndex), `Tool T${toolIndex + 1} heating turned off`); + }); + + router.post('/printer/temperature/chamber', async (req: AuthenticatedRequest, res: Response) => { + const validation = TemperatureSetRequestSchema.safeParse(req.body); + if (!validation.success) { + return sendErrorResponse(res, 400, createValidationError(validation.error).error); + } + const temperature = Math.min(Math.round(validation.data.temperature), CHAMBER_MAX_TEMP); + return handleTempControl(req, res, deps, (client) => client.tempControl.setChamberTemp(temperature), `Setting chamber temperature to ${temperature}°C`); + }); + + router.post('/printer/temperature/chamber/off', async (req: AuthenticatedRequest, res: Response) => { + return handleTempControl(req, res, deps, (client) => client.tempControl.cancelChamberTemp(), 'Chamber heating turned off'); + }); +} + +/** + * Run a FiveMClient temperature-control action for the resolved context. Used by the + * Creator 5 per-tool and chamber heaters, which have no G-code passthrough. + */ +async function handleTempControl( + req: AuthenticatedRequest, + res: Response, + deps: RouteDependencies, + action: (client: FiveMClient) => Promise, + successMessage: string +): Promise { + try { + const contextResult = resolveContext(req, deps, { + requireBackendReady: true, + requireBackendInstance: true, + }); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const { backend } = contextResult; + const primaryClient = backend?.getPrimaryClient(); + if (!(primaryClient instanceof FiveMClient)) { + return sendErrorResponse(res, 400, 'Temperature control requires new API client'); + } + + const result = await action(primaryClient); + const response: StandardAPIResponse = { + success: result, + message: result ? successMessage : undefined, + error: result ? undefined : 'Temperature command failed', + }; + return res.status(result ? 200 : 500).json(response); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } } async function handleSimpleTemperatureCommand( diff --git a/src/webui/static/app.ts b/src/webui/static/app.ts index 634359d..575b824 100644 --- a/src/webui/static/app.ts +++ b/src/webui/static/app.ts @@ -108,6 +108,15 @@ export interface WebSocketCommand { data?: unknown; } +/** + * Per-tool temperature reading for multi-tool printers (Creator 5 series). + * One entry per nozzle; index 0 maps to the printer's T1 in the UI. + */ +export interface ToolTemperature { + current: number; + target: number; +} + export interface PrinterStatus { printerState: string; bedTemperature: number; @@ -128,6 +137,13 @@ export interface PrinterStatus { cumulativePrintTime?: number; // Total lifetime print time in minutes formattedEta?: string; // Firmware ETA string (e.g. "04:48" = 4h48m remaining) elapsedTimeSeconds?: number; // Precise elapsed seconds for HH:MM:SS display + // Creator 5 series (multi-tool) fields. Undefined/empty on single-nozzle printers. + toolTemps?: ToolTemperature[]; + chamberTemperature?: number; + chamberTargetTemperature?: number; + hasChamberControl?: boolean; + isCreator5Pro?: boolean; + tvocLevel?: number; } export interface PrinterFeatures { @@ -139,6 +155,10 @@ export interface PrinterFeatures { canResume: boolean; canCancel: boolean; ledUsesLegacyAPI?: boolean; // Whether custom LED control is enabled + /** Multi-tool printer (Creator 5 series) — gates the per-tool temperature card. */ + hasMultiTool?: boolean; + /** Creator 5 Pro — gates the read-only TVOC air-quality display. */ + isCreator5Pro?: boolean; } export interface AD5XToolData { @@ -231,6 +251,12 @@ export interface MaterialStationStatus { activeSlot: number | null; overallStatus: 'ready' | 'warming' | 'error' | 'disconnected'; errorMessage: string | null; + /** + * Printer model backing this station, used to resolve the correct fixed + * filament palette (AD5X vs Creator 5) in the slot editor. Optional for + * backward compatibility with older status payloads. + */ + printerModelType?: string; } export interface MaterialStationStatusResponse extends ApiResponse { @@ -367,8 +393,13 @@ async function initialize(): Promise { closeMaterialMatchingModal(); }, onMaterialMatchingConfirm: () => confirmMaterialMatching(), - onTemperatureSubmit: (type, temperature) => - sendPrinterCommand(`temperature/${type}`, { temperature }), + onTemperatureSubmit: (target, temperature) => { + const endpoint = + target.kind === 'tool' + ? `temperature/tool/${target.index}` + : `temperature/${target.kind}`; + return sendPrinterCommand(endpoint, { temperature }); + }, }; setupDialogEventHandlers(dialogHandlers); setupJobControlEventHandlers(); diff --git a/src/webui/static/features/ifs-station.ts b/src/webui/static/features/ifs-station.ts index ef8183e..f2abf34 100644 --- a/src/webui/static/features/ifs-station.ts +++ b/src/webui/static/features/ifs-station.ts @@ -1,16 +1,19 @@ /** - * @fileoverview AD5X IFS material-station dashboard card + slot editor. + * @fileoverview Material-station dashboard card + slot editor (AD5X + Creator 5). * * Renders the four material-station slots as a grid card (swatch + material, * mirroring the desktop FlashForgeUI card) that refreshes from the printer's - * cached material-station status on each status tick. Clicking a slot opens a - * manual editor modal: a material dropdown (14 recognized materials) and a grid - * of the 24 recognized color swatches (see {@link ../shared/ifs-palette.js}), - * pre-seeded from the slot's current state. When Spoolman is configured, the - * editor also offers a "Set from Spoolman" shortcut that pre-fills the - * selections (snapped to the fixed palette) for review before applying. The - * chosen material/color are written via the slot-config route, which calls the - * library's `configureSlot`. + * cached material-station status on each status tick. The AD5X calls this the + * "IFS"; the Creator 5 series calls it the "Material Station" — same card, the + * only difference is the fixed filament palette, resolved per model via + * {@link ../shared/ifs-palette.js} `getPaletteForModel`. Clicking a slot opens a + * manual editor modal: a material dropdown and a grid of the recognized color + * swatches, pre-seeded from the slot's current state. When Spoolman is + * configured, the editor also offers a "Set from Spoolman" shortcut that + * pre-fills the selections (snapped to the model's palette) for review before + * applying. The chosen material/color are written via the slot-config route, + * which calls the library's `configureSlot` (which normalizes the color `#` per + * model on the wire). */ import type { @@ -23,7 +26,7 @@ import type { import { state } from '../core/AppState.js'; import { apiRequest } from '../core/Transport.js'; import { $, showToast } from '../shared/dom.js'; -import { IFS_COLORS, IFS_MATERIALS, nearestColor, nearestMaterial } from '../shared/ifs-palette.js'; +import { getPaletteForModel } from '../shared/ifs-palette.js'; import { getCurrentContextId } from './context-switching.js'; import { openSpoolPicker } from './spoolman.js'; @@ -191,15 +194,24 @@ export function setupIfsStationCard(): void { function openSlotEditor(slot: MaterialSlotInfo): void { const displaySlotId = slot.slotId + 1; + // Resolve the fixed palette for this printer model (AD5X vs Creator 5). + const palette = getPaletteForModel(latestStation?.printerModelType); + const paletteMaterials = palette.materials; + const paletteColors = palette.colors; + // Seed from the slot's current material/color, snapped to the fixed palette. let selectedMaterial = - (slot.materialType ? nearestMaterial(slot.materialType) : null) ?? IFS_MATERIALS[0] ?? 'PLA'; - let selectedHex: string | null = slot.materialColor ? (nearestColor(slot.materialColor)?.hex ?? null) : null; - - const materialOptions = IFS_MATERIALS.map( - (m) => `` - ).join(''); - const swatches = IFS_COLORS.map( + (slot.materialType ? palette.nearestMaterial(slot.materialType) : null) ?? + paletteMaterials[0] ?? + 'PLA'; + let selectedHex: string | null = slot.materialColor + ? (palette.nearestColor(slot.materialColor)?.hex ?? null) + : null; + + const materialOptions = paletteMaterials + .map((m) => ``) + .join(''); + const swatches = paletteColors.map( (c) => `` ).join(''); @@ -245,7 +257,7 @@ function openSlotEditor(slot: MaterialSlotInfo): void { const updatePreview = (): void => { const colorName = selectedHex - ? (IFS_COLORS.find((c) => c.hex === selectedHex)?.name ?? selectedHex) + ? (paletteColors.find((c) => c.hex === selectedHex)?.name ?? selectedHex) : null; previewEl.textContent = colorName ? `Slot ${displaySlotId} → ${selectedMaterial} · ${colorName}` @@ -289,7 +301,7 @@ function openSlotEditor(slot: MaterialSlotInfo): void { overlay.querySelector('.ifs-editor-spoolman')?.addEventListener('click', () => { openSpoolPicker((spool) => { if (spool.material) { - const matched = nearestMaterial(spool.material); + const matched = palette.nearestMaterial(spool.material); if (matched) { selectedMaterial = matched; select.value = matched; @@ -297,7 +309,7 @@ function openSlotEditor(slot: MaterialSlotInfo): void { } const rawColor = getRawSpoolColor(spool); if (rawColor) { - const snapped = nearestColor(rawColor); + const snapped = palette.nearestColor(rawColor); if (snapped) selectSwatch(snapped.hex); } updatePreview(); @@ -338,7 +350,8 @@ async function applyManualSlot( }); if (result.success) { - const colorName = IFS_COLORS.find((c) => c.hex === colorHex)?.name ?? colorHex; + const palette = getPaletteForModel(latestStation?.printerModelType); + const colorName = palette.colors.find((c) => c.hex === colorHex)?.name ?? colorHex; showToast(`Slot ${displaySlotId} → ${material} · ${colorName}`, 'success'); onApplied(); await refreshIfsStationCard(); diff --git a/src/webui/static/features/job-control.ts b/src/webui/static/features/job-control.ts index 9072ad7..56085bc 100644 --- a/src/webui/static/features/job-control.ts +++ b/src/webui/static/features/job-control.ts @@ -18,7 +18,7 @@ import { getCurrentSettings, state } from '../core/AppState.js'; import { apiRequest, sendCommand } from '../core/Transport.js'; import { $, hideElement, showToast } from '../shared/dom.js'; import { isAD5XJobFile, isMultiColorJobFile } from '../shared/formatting.js'; -import { loadFileList, showTemperatureDialog } from '../ui/dialogs.js'; +import { loadFileList, showTemperatureDialog, type TemperatureTarget } from '../ui/dialogs.js'; import { applySettings, refreshSettingsUI } from './layout-theme.js'; import { openMaterialMatchingModal } from './material-matching.js'; @@ -177,6 +177,46 @@ export async function sendJobStartRequest(options: JobStartOptions): Promise= 0) { + return { kind: 'tool', index }; + } + } + return null; +} + +/** Handle a Set/Off click on the Creator 5 multi-tool temperature card. */ +async function handleCreator5TempButton( + action: 'set' | 'off', + button: HTMLButtonElement +): Promise { + const target = resolveHeaterTarget(button); + if (!target) { + return; + } + + if (action === 'set') { + showTemperatureDialog(target); + return; + } + + const endpoint = + target.kind === 'tool' + ? `temperature/tool/${target.index}/off` + : `temperature/${target.kind}/off`; + await sendPrinterCommand(endpoint); +} + export function setupJobControlEventHandlers(): void { const containers = [$('webui-grid-desktop'), $('webui-grid-mobile')]; @@ -192,6 +232,15 @@ export function setupJobControlEventHandlers(): void { return; } + // Creator 5 multi-tool card buttons are dynamic (per-tool), so they are keyed + // by data attributes rather than fixed ids. + const c5Action = button.dataset.c5Action; + if (c5Action === 'set' || c5Action === 'off') { + await handleCreator5TempButton(c5Action, button); + event.preventDefault(); + return; + } + let handled = true; switch (button.id) { case 'btn-led-on': @@ -216,13 +265,13 @@ export function setupJobControlEventHandlers(): void { await sendPrinterCommand('control/cancel'); break; case 'btn-bed-set': - showTemperatureDialog('bed'); + showTemperatureDialog({ kind: 'bed' }); break; case 'btn-bed-off': await sendPrinterCommand('temperature/bed/off'); break; case 'btn-extruder-set': - showTemperatureDialog('extruder'); + showTemperatureDialog({ kind: 'extruder' }); break; case 'btn-extruder-off': await sendPrinterCommand('temperature/extruder/off'); diff --git a/src/webui/static/features/layout-theme.ts b/src/webui/static/features/layout-theme.ts index f5583f9..cdb8a25 100644 --- a/src/webui/static/features/layout-theme.ts +++ b/src/webui/static/features/layout-theme.ts @@ -486,7 +486,19 @@ export function isComponentSupported( } if (componentId === 'filtration-tvoc') { - return Boolean(features.hasFiltration); + // Shown for filtration-capable printers, and for the Creator 5 Pro which exposes + // a read-only TVOC air-quality reading (its filtration controls are hidden). + return Boolean(features.hasFiltration) || Boolean(features.isCreator5Pro); + } + + // The single-nozzle temperature card and the multi-tool (Creator 5) card are + // mutually exclusive — pick one based on the printer's tool configuration. + if (componentId === 'temp-control') { + return !features.hasMultiTool; + } + + if (componentId === 'creator5-temperature') { + return Boolean(features.hasMultiTool); } if (componentId === 'spoolman-tracker') { diff --git a/src/webui/static/grid/WebUIComponentRegistry.ts b/src/webui/static/grid/WebUIComponentRegistry.ts index c8d2ffe..0a19539 100644 --- a/src/webui/static/grid/WebUIComponentRegistry.ts +++ b/src/webui/static/grid/WebUIComponentRegistry.ts @@ -54,6 +54,13 @@ const COMPONENT_DEFINITIONS: Record = { minSize: { w: 2, h: 2 }, defaultPosition: { x: 3, y: 6 }, }, + 'creator5-temperature': { + id: 'creator5-temperature', + displayName: 'Temperature (Multi-tool)', + defaultSize: { w: 3, h: 4 }, + minSize: { w: 2, h: 3 }, + defaultPosition: { x: 3, y: 6 }, + }, 'filtration-tvoc': { id: 'filtration-tvoc', displayName: 'Filtration & TVOC', @@ -191,6 +198,36 @@ const COMPONENT_TEMPLATES: Record = { `, }, + 'creator5-temperature': { + id: 'creator5-temperature', + html: ` +
+
Temperature
+
+ +
+
+
+ Bed: --°C / --°C +
+ + +
+
+ +
+
+
+ `, + }, 'filtration-tvoc': { id: 'filtration-tvoc', html: ` @@ -350,6 +387,7 @@ const DEFAULT_LAYOUT_COMPONENTS: WebUIComponentLayoutMap = { 'model-preview': { x: 6, y: 4, w: 6, h: 2 }, 'printer-state': { x: 0, y: 6, w: 3, h: 2 }, 'temp-control': { x: 3, y: 6, w: 3, h: 2 }, + 'creator5-temperature': { x: 3, y: 6, w: 3, h: 4 }, 'job-progress': { x: 6, y: 6, w: 6, h: 2 }, 'filtration-tvoc': { x: 0, y: 8, w: 3, h: 2 }, 'spoolman-tracker': { x: 3, y: 8, w: 3, h: 2 }, diff --git a/src/webui/static/grid/WebUIMobileLayoutManager.ts b/src/webui/static/grid/WebUIMobileLayoutManager.ts index 89d8976..d0fb693 100644 --- a/src/webui/static/grid/WebUIMobileLayoutManager.ts +++ b/src/webui/static/grid/WebUIMobileLayoutManager.ts @@ -13,6 +13,7 @@ export class WebUIMobileLayoutManager { 'controls', 'printer-state', 'temp-control', + 'creator5-temperature', 'spoolman-tracker', 'ifs-station', 'model-preview', diff --git a/src/webui/static/index.html b/src/webui/static/index.html index 062efa4..0475a21 100644 --- a/src/webui/static/index.html +++ b/src/webui/static/index.html @@ -98,6 +98,7 @@

Panel Visibility

+ diff --git a/src/webui/static/shared/__tests__/ifs-palette.test.ts b/src/webui/static/shared/__tests__/ifs-palette.test.ts index e23d21e..f79fabf 100644 --- a/src/webui/static/shared/__tests__/ifs-palette.test.ts +++ b/src/webui/static/shared/__tests__/ifs-palette.test.ts @@ -1,16 +1,26 @@ /** - * @fileoverview Tests for the AD5X IFS palette nearest-match snapping. + * @fileoverview Tests for the material-station palette nearest-match snapping. * * Validates that every fixed swatch resolves to itself, that small RGB * perturbations snap back, that a curated set of known off-palette colors map * to the expected swatch (including cases that a naive RGB/ΔE76 distance gets * wrong but CIEDE2000 gets right), that hex-format variants parse correctly, * and that material snapping handles exact, leading-token, and unrecognized - * cases. All fixtures are static; nothing here contacts a Spoolman instance. + * cases. Also covers per-model palette selection (AD5X vs Creator 5) via + * {@link getPaletteForModel}. All fixtures are static; nothing here contacts a + * Spoolman instance. */ import { describe, expect, it } from '@jest/globals'; -import { IFS_COLORS, IFS_MATERIALS, nearestColor, nearestMaterial } from '../ifs-palette.js'; +import { + AD5X_PALETTE, + CREATOR5_PALETTE, + getPaletteForModel, + IFS_COLORS, + IFS_MATERIALS, + nearestColor, + nearestMaterial, +} from '../ifs-palette.js'; function toHex(r: number, g: number, b: number): string { const clamp = (v: number): number => Math.max(0, Math.min(255, v)); @@ -134,3 +144,28 @@ describe('nearestMaterial', () => { expect(IFS_MATERIALS).toContain('PPS-CF'); }); }); + +describe('getPaletteForModel', () => { + it('selects the Creator 5 palette for creator-5 models and AD5X otherwise', () => { + expect(getPaletteForModel('creator-5')).toBe(CREATOR5_PALETTE); + expect(getPaletteForModel('creator-5-pro')).toBe(CREATOR5_PALETTE); + expect(getPaletteForModel('ad5x')).toBe(AD5X_PALETTE); + expect(getPaletteForModel(undefined)).toBe(AD5X_PALETTE); + expect(getPaletteForModel(null)).toBe(AD5X_PALETTE); + }); + + it('exposes 24 colors for both models with distinct blues', () => { + expect(AD5X_PALETTE.colors).toHaveLength(24); + expect(CREATOR5_PALETTE.colors).toHaveLength(24); + // Every color but White differs between the two palettes. + const ad5xBlue = AD5X_PALETTE.colors.find((c) => c.name === 'Blue')?.hex; + const c5Blue = CREATOR5_PALETTE.colors.find((c) => c.name === 'Blue')?.hex; + expect(ad5xBlue).not.toEqual(c5Blue); + }); + + it('exposes the Creator 5 material list (21 materials incl. ASA)', () => { + expect(CREATOR5_PALETTE.materials).toHaveLength(21); + expect(CREATOR5_PALETTE.nearestMaterial('ASA')).toBe('ASA'); + expect(CREATOR5_PALETTE.nearestColor('#4CAAF8')?.name).toBe('Blue'); + }); +}); diff --git a/src/webui/static/shared/ifs-palette.ts b/src/webui/static/shared/ifs-palette.ts index fcb5dc6..7d204f5 100644 --- a/src/webui/static/shared/ifs-palette.ts +++ b/src/webui/static/shared/ifs-palette.ts @@ -1,12 +1,15 @@ /** - * @fileoverview Fixed AD5X IFS material/color palette plus nearest-match snapping. + * @fileoverview Fixed FlashForge material-station palettes plus nearest-match snapping. * - * The AD5X material station only renders 14 known materials and 24 known colors; - * arbitrary Spoolman values won't draw an icon on the printer screen. This pure, - * DOM-free module holds those fixed lists and snaps an arbitrary material/color to - * the closest recognized swatch. Color matching uses CIEDE2000 (not ΔE76) because - * ΔE76 mismatches saturated blue/red regions on real Spoolman data. The algorithm - * is kept identical to the Electron app's copy so both behave the same. + * FlashForge printers only render a fixed set of materials and colors on their + * material station; arbitrary Spoolman values won't draw an icon on the printer + * screen. This pure, DOM-free module holds those fixed lists and snaps an + * arbitrary material/color to the closest recognized swatch. Color matching uses + * CIEDE2000 (not ΔE76) because ΔE76 mismatches saturated blue/red regions on real + * Spoolman data. The AD5X and Creator 5 series use different palettes — resolve + * per model via {@link getPaletteForModel}. The legacy `IFS_*` / `nearestColor` / + * `nearestMaterial` exports remain as AD5X aliases for backward compatibility. The + * algorithm is kept identical to the Electron app's copy so both behave the same. */ export interface PaletteColor { @@ -16,52 +19,6 @@ export interface PaletteColor { hex: string; } -/** The 14 materials the AD5X UI renders (order matches the API docs). */ -export const IFS_MATERIALS: readonly string[] = [ - 'PLA', - 'PLA-CF', - 'PETG', - 'PETG-CF', - 'ABS', - 'TPU', - 'SILK', - 'PA', - 'PA-CF', - 'PAHT-CF', - 'PC', - 'PC-ABS', - 'PET-CF', - 'PPS-CF', -]; - -/** The 24 colors the AD5X UI renders. */ -export const IFS_COLORS: readonly PaletteColor[] = [ - { name: 'White', hex: '#FFFFFF' }, - { name: 'Yellow', hex: '#FEF043' }, - { name: 'Light Green', hex: '#DCF478' }, - { name: 'Green', hex: '#0ACC38' }, - { name: 'Dark Green', hex: '#067749' }, - { name: 'Teal', hex: '#0C6283' }, - { name: 'Cyan', hex: '#0DE2A0' }, - { name: 'Light Blue', hex: '#75D9F3' }, - { name: 'Blue', hex: '#45A8F9' }, - { name: 'Dark Blue', hex: '#2750E0' }, - { name: 'Purple', hex: '#46328E' }, - { name: 'Violet', hex: '#A03CF7' }, - { name: 'Magenta', hex: '#F330F9' }, - { name: 'Pink', hex: '#D4B0DC' }, - { name: 'Coral', hex: '#F95D73' }, - { name: 'Red', hex: '#F72224' }, - { name: 'Brown', hex: '#7C4B00' }, - { name: 'Orange', hex: '#F98D33' }, - { name: 'Cream', hex: '#FDEBD5' }, - { name: 'Tan', hex: '#D3C4A3' }, - { name: 'Dark Brown', hex: '#AF7836' }, - { name: 'Gray', hex: '#898989' }, - { name: 'Light Gray', hex: '#BCBCBC' }, - { name: 'Black', hex: '#161616' }, -]; - type Lab = readonly [number, number, number]; function parseHex(hex: string): [number, number, number] | null { @@ -158,46 +115,194 @@ function ciede2000(lab1: Lab, lab2: Lab): number { ); } -const PALETTE_LAB: ReadonlyArray<{ color: PaletteColor; lab: Lab }> = IFS_COLORS.map((color) => { - const lab = hexToLab(color.hex); - if (!lab) throw new Error(`Invalid palette hex: ${color.hex}`); - return { color, lab }; -}); +function normalizeMaterial(raw: string): string { + return raw.toUpperCase().replace(/[^A-Z0-9]/g, ''); +} /** - * Snap an arbitrary hex (#RRGGBB, RRGGBB, RRGGBBAA, or #RGB) to the nearest palette - * swatch via CIEDE2000. Returns null if unparseable. + * A model's fixed filament palette: recognized colors + materials, plus the + * nearest-match helpers that snap arbitrary input onto them. The matching behavior + * is identical across models (data-only difference). */ -export function nearestColor(hex: string): PaletteColor | null { - const lab = hexToLab(hex); - if (!lab) return null; - let best: PaletteColor | null = null; - let bestD = Number.POSITIVE_INFINITY; - for (const entry of PALETTE_LAB) { - const d = ciede2000(lab, entry.lab); - if (d < bestD) { - bestD = d; - best = entry.color; +export class Palette { + public readonly colors: readonly PaletteColor[]; + public readonly materials: readonly string[]; + + private readonly paletteLab: ReadonlyArray<{ color: PaletteColor; lab: Lab }>; + private readonly materialNorm: ReadonlyMap; + + constructor(colors: readonly PaletteColor[], materials: readonly string[]) { + this.colors = colors; + this.materials = materials; + this.paletteLab = colors.map((color) => { + const lab = hexToLab(color.hex); + if (!lab) throw new Error(`Invalid palette hex: ${color.hex}`); + return { color, lab }; + }); + this.materialNorm = new Map(materials.map((m) => [normalizeMaterial(m), m])); + } + + /** Snap an arbitrary hex to the nearest palette swatch via CIEDE2000. */ + public nearestColor(hex: string): PaletteColor | null { + const lab = hexToLab(hex); + if (!lab) return null; + let best: PaletteColor | null = null; + let bestD = Number.POSITIVE_INFINITY; + for (const entry of this.paletteLab) { + const d = ciede2000(lab, entry.lab); + if (d < bestD) { + bestD = d; + best = entry.color; + } } + return best; } - return best; -} -function normalizeMaterial(raw: string): string { - return raw.toUpperCase().replace(/[^A-Z0-9]/g, ''); + /** Snap a raw material to a recognized material: exact, else leading token, else null. */ + public nearestMaterial(raw: string): string | null { + if (typeof raw !== 'string' || raw.trim() === '') return null; + const exact = this.materialNorm.get(normalizeMaterial(raw)); + if (exact) return exact; + const leading = raw.trim().split(/\s+/)[0]; + return this.materialNorm.get(normalizeMaterial(leading)) ?? null; + } } -const MATERIAL_NORM = new Map( - IFS_MATERIALS.map((m) => [normalizeMaterial(m), m]) -); + +/** The 14 materials the AD5X UI renders (order matches the API docs). */ +export const AD5X_MATERIALS: readonly string[] = [ + 'PLA', + 'PLA-CF', + 'PETG', + 'PETG-CF', + 'ABS', + 'TPU', + 'SILK', + 'PA', + 'PA-CF', + 'PAHT-CF', + 'PC', + 'PC-ABS', + 'PET-CF', + 'PPS-CF', +]; + +/** The 24 colors the AD5X UI renders. */ +export const AD5X_COLORS: readonly PaletteColor[] = [ + { name: 'White', hex: '#FFFFFF' }, + { name: 'Yellow', hex: '#FEF043' }, + { name: 'Light Green', hex: '#DCF478' }, + { name: 'Green', hex: '#0ACC38' }, + { name: 'Dark Green', hex: '#067749' }, + { name: 'Teal', hex: '#0C6283' }, + { name: 'Cyan', hex: '#0DE2A0' }, + { name: 'Light Blue', hex: '#75D9F3' }, + { name: 'Blue', hex: '#45A8F9' }, + { name: 'Dark Blue', hex: '#2750E0' }, + { name: 'Purple', hex: '#46328E' }, + { name: 'Violet', hex: '#A03CF7' }, + { name: 'Magenta', hex: '#F330F9' }, + { name: 'Pink', hex: '#D4B0DC' }, + { name: 'Coral', hex: '#F95D73' }, + { name: 'Red', hex: '#F72224' }, + { name: 'Brown', hex: '#7C4B00' }, + { name: 'Orange', hex: '#F98D33' }, + { name: 'Cream', hex: '#FDEBD5' }, + { name: 'Tan', hex: '#D3C4A3' }, + { name: 'Dark Brown', hex: '#AF7836' }, + { name: 'Gray', hex: '#898989' }, + { name: 'Light Gray', hex: '#BCBCBC' }, + { name: 'Black', hex: '#161616' }, +]; + +/** The AD5X fixed palette. */ +export const AD5X_PALETTE = new Palette(AD5X_COLORS, AD5X_MATERIALS); + +/** + * The 21 materials the Creator 5 UI renders (firmware order). New vs AD5X: ASA, + * S-PAHT, S-Multi, HIPS, PVA, and three TPU durometers. + */ +export const CREATOR5_MATERIALS: readonly string[] = [ + 'PLA', + 'PETG', + 'PLA-CF', + 'PETG-CF', + 'ABS', + 'ASA', + 'SILK', + 'PET-CF', + 'PAHT-CF', + 'S-PAHT', + 'S-Multi', + 'PA-CF', + 'HIPS', + 'PVA', + 'TPU-90A', + 'TPU-95A', + 'TPU-64D', + 'PC', + 'PA', + 'PC-ABS', + 'PPS-CF', +]; + +/** The 24 colors the Creator 5 UI renders (differ from every AD5X swatch except White). */ +export const CREATOR5_COLORS: readonly PaletteColor[] = [ + { name: 'White', hex: '#FFFFFF' }, + { name: 'Yellow', hex: '#FFF245' }, + { name: 'Light Green', hex: '#DEF578' }, + { name: 'Green', hex: '#21CC3D' }, + { name: 'Dark Green', hex: '#167A4B' }, + { name: 'Teal', hex: '#156682' }, + { name: 'Cyan', hex: '#24E4A0' }, + { name: 'Light Blue', hex: '#7BD9F0' }, + { name: 'Blue', hex: '#4CAAF8' }, + { name: 'Dark Blue', hex: '#2E54DD' }, + { name: 'Purple', hex: '#48358C' }, + { name: 'Violet', hex: '#A341F7' }, + { name: 'Magenta', hex: '#F435F6' }, + { name: 'Pink', hex: '#D5B4DE' }, + { name: 'Coral', hex: '#FA6173' }, + { name: 'Red', hex: '#F82D29' }, + { name: 'Brown', hex: '#805003' }, + { name: 'Orange', hex: '#F9903B' }, + { name: 'Cream', hex: '#FCEBD7' }, + { name: 'Tan', hex: '#D5C5A1' }, + { name: 'Dark Brown', hex: '#B17C38' }, + { name: 'Gray', hex: '#8C8C89' }, + { name: 'Light Gray', hex: '#BEBEBE' }, + { name: 'Black', hex: '#1B1B1B' }, +]; + +/** The Creator 5 / 5 Pro fixed palette. */ +export const CREATOR5_PALETTE = new Palette(CREATOR5_COLORS, CREATOR5_MATERIALS); /** - * Snap a raw Spoolman material to a recognized material: exact (normalized) match, - * else leading-token match, else null (caller keeps current slot material). + * Resolve the fixed filament palette for a printer model. The Creator 5 / 5 Pro + * use their own newer palette; every other material-station printer (the AD5X) + * uses the AD5X palette, which is also the safe default for an unknown model. */ +export function getPaletteForModel(modelType: string | undefined | null): Palette { + if (modelType === 'creator-5' || modelType === 'creator-5-pro') { + return CREATOR5_PALETTE; + } + return AD5X_PALETTE; +} + +// --------------------------------------------------------------------------- +// Legacy AD5X aliases (kept so existing importers compile unchanged). +// --------------------------------------------------------------------------- + +/** @deprecated Use {@link getPaletteForModel}. AD5X material list. */ +export const IFS_MATERIALS = AD5X_MATERIALS; +/** @deprecated Use {@link getPaletteForModel}. AD5X color list. */ +export const IFS_COLORS = AD5X_COLORS; + +/** @deprecated Use `getPaletteForModel(model).nearestColor`. Snaps against the AD5X palette. */ +export function nearestColor(hex: string): PaletteColor | null { + return AD5X_PALETTE.nearestColor(hex); +} + +/** @deprecated Use `getPaletteForModel(model).nearestMaterial`. Snaps against the AD5X palette. */ export function nearestMaterial(raw: string): string | null { - if (typeof raw !== 'string' || raw.trim() === '') return null; - const exact = MATERIAL_NORM.get(normalizeMaterial(raw)); - if (exact) return exact; - const leading = raw.trim().split(/\s+/)[0]; - return MATERIAL_NORM.get(normalizeMaterial(leading)) ?? null; + return AD5X_PALETTE.nearestMaterial(raw); } diff --git a/src/webui/static/ui/dialogs.ts b/src/webui/static/ui/dialogs.ts index af5e061..8e9a073 100644 --- a/src/webui/static/ui/dialogs.ts +++ b/src/webui/static/ui/dialogs.ts @@ -18,15 +18,59 @@ import { isMultiColorJobFile, } from '../shared/formatting.js'; +/** + * A settable heater target. Single-nozzle printers use `bed`/`extruder`; the + * Creator 5 series adds `chamber` and per-`tool` heaters (index is 0-based on the + * wire, displayed as T1..Tn in the UI). + */ +export type TemperatureTarget = + | { kind: 'bed' } + | { kind: 'extruder' } + | { kind: 'chamber' } + | { kind: 'tool'; index: number }; + interface TemperatureDialogElement extends HTMLElement { - temperatureType?: 'bed' | 'extruder'; + temperatureTarget?: TemperatureTarget; } +/** Firmware chamber temperature ceiling (matches the desktop Creator 5 card). */ +const CHAMBER_MAX_TEMP = 80; + export interface DialogHandlers { onStartPrintJob?: () => Promise | void; onMaterialMatchingClosed?: () => void; onMaterialMatchingConfirm?: () => Promise | void; - onTemperatureSubmit?: (type: 'bed' | 'extruder', temperature: number) => Promise | void; + onTemperatureSubmit?: (target: TemperatureTarget, temperature: number) => Promise | void; +} + +function temperatureTargetLabel(target: TemperatureTarget): string { + switch (target.kind) { + case 'bed': + return 'Bed'; + case 'extruder': + return 'Extruder'; + case 'chamber': + return 'Chamber'; + case 'tool': + return `Tool T${target.index + 1}`; + } +} + +function currentTargetTemperature(target: TemperatureTarget): number { + const status = state.printerStatus; + if (!status) { + return 0; + } + switch (target.kind) { + case 'bed': + return status.bedTargetTemperature; + case 'extruder': + return status.nozzleTargetTemperature; + case 'chamber': + return status.chamberTargetTemperature ?? 0; + case 'tool': + return status.toolTemps?.[target.index]?.target ?? 0; + } } let dialogHandlers: DialogHandlers = {}; @@ -161,7 +205,7 @@ export function showFileModal(files: WebUIJobFile[], source: 'recent' | 'local') showElement('file-modal'); } -export function showTemperatureDialog(type: 'bed' | 'extruder'): void { +export function showTemperatureDialog(target: TemperatureTarget): void { const dialog = $('temp-dialog'); const title = $('temp-dialog-title'); const message = $('temp-dialog-message'); @@ -171,20 +215,16 @@ export function showTemperatureDialog(type: 'bed' | 'extruder'): void { return; } - title.textContent = type === 'bed' ? 'Set Bed Temperature' : 'Set Extruder Temperature'; - message.textContent = `Enter ${type} temperature (°C):`; + const label = temperatureTargetLabel(target); + const maxNote = target.kind === 'chamber' ? `, max ${CHAMBER_MAX_TEMP}` : ''; + title.textContent = `Set ${label} Temperature`; + message.textContent = `Enter ${label} temperature (°C)${maxNote}:`; - if (state.printerStatus) { - const currentTarget = - type === 'bed' - ? state.printerStatus.bedTargetTemperature - : state.printerStatus.nozzleTargetTemperature; - input.value = Math.round(currentTarget).toString(); - } else { - input.value = '0'; - } + input.value = state.printerStatus + ? Math.round(currentTargetTemperature(target)).toString() + : '0'; - (dialog as TemperatureDialogElement).temperatureType = type; + (dialog as TemperatureDialogElement).temperatureTarget = target; showElement('temp-dialog'); input.focus(); input.select(); @@ -198,15 +238,17 @@ export async function setTemperature(): Promise { return; } - const type = dialog.temperatureType; + const target = dialog.temperatureTarget; const temperature = parseInt(input.value, 10); - if (!type) { + if (!target) { showToast('Unknown temperature target', 'error'); return; } - if (Number.isNaN(temperature) || temperature < 0 || temperature > 300) { + // Chamber is capped at CHAMBER_MAX_TEMP by the range check below (no separate clamp needed). + const maxTemperature = target.kind === 'chamber' ? CHAMBER_MAX_TEMP : 300; + if (Number.isNaN(temperature) || temperature < 0 || temperature > maxTemperature) { showToast('Invalid temperature value', 'error'); return; } @@ -217,7 +259,7 @@ export async function setTemperature(): Promise { } try { - await dialogHandlers.onTemperatureSubmit(type, temperature); + await dialogHandlers.onTemperatureSubmit(target, temperature); hideElement('temp-dialog'); } catch (error) { console.error('Failed to submit temperature command:', error); diff --git a/src/webui/static/ui/panels.ts b/src/webui/static/ui/panels.ts index 50074f9..8e295fc 100644 --- a/src/webui/static/ui/panels.ts +++ b/src/webui/static/ui/panels.ts @@ -46,6 +46,7 @@ export function updatePrinterStatus(status: PrinterStatus | null): void { setTextContent('current-job', 'No data'); setTextContent('progress-percentage', '0%'); updateModelPreview(null); + updateCreator5TemperatureCard(null); return; } @@ -135,6 +136,121 @@ export function updatePrinterStatus(status: PrinterStatus | null): void { updateButtonStates(status.printerState || 'Unknown'); updateFiltrationStatus(status.filtrationMode); + updateCreator5TemperatureCard(status); + updateTvocDisplay(status); +} + +/** `°C / °C`, guarding against NaN readings. */ +function formatTempPair(current: number, target: number): string { + const c = Number.isNaN(current) ? 0 : Math.round(current); + const t = Number.isNaN(target) ? 0 : Math.round(target); + return `${c}°C / ${t}°C`; +} + +/** Whether heater Set/Off controls should be disabled for the given printer state. */ +function isHeaterControlDisabled(printerState: string): boolean { + const isPrintingActive = + printerState === 'Printing' || + printerState === 'Paused' || + printerState === 'Calibrating' || + printerState === 'Heating' || + printerState === 'Pausing'; + const isBusy = printerState === 'Busy' || printerState === 'Error'; + return isPrintingActive || isBusy; +} + +/** + * Render the Creator 5 multi-tool temperature card (per-tool heaters + bed + + * chamber). Self-gates: shows an "unavailable" message when the printer does not + * report per-tool temperatures. No-ops when the card is not mounted (non-C5). + */ +export function updateCreator5TemperatureCard(status: PrinterStatus | null): void { + const panel = document.querySelector('[data-component-id="creator5-temperature"]'); + if (!panel) { + return; + } + + const unavailable = $('c5-temps-unavailable'); + const body = $('c5-temps-body'); + const toolGrid = $('c5-tool-grid'); + const chamberRow = $('c5-chamber-row'); + + const toolTemps = status?.toolTemps ?? []; + const available = Boolean(status) && toolTemps.length > 0; + + unavailable?.classList.toggle('hidden', available); + body?.classList.toggle('hidden', !available); + + if (!available || !status) { + return; + } + + // Rebuild the per-tool rows only when the tool count changes. + if (toolGrid) { + const existingRows = toolGrid.querySelectorAll('.temp-row').length; + if (existingRows !== toolTemps.length) { + toolGrid.innerHTML = toolTemps + .map( + (_, i) => ` +
+ T${i + 1}: --°C / --°C +
+ + +
+
` + ) + .join(''); + } + toolTemps.forEach((tool, i) => { + const reading = toolGrid.querySelector(`[data-tool-reading="${i}"]`); + if (reading) { + reading.textContent = formatTempPair(tool.current, tool.target); + } + }); + } + + setTextContent('c5-bed-temp', formatTempPair(status.bedTemperature, status.bedTargetTemperature)); + + const hasChamber = Boolean(status.hasChamberControl) && status.chamberTemperature !== undefined; + chamberRow?.classList.toggle('hidden', !hasChamber); + if (hasChamber) { + setTextContent( + 'c5-chamber-temp', + formatTempPair(status.chamberTemperature ?? 0, status.chamberTargetTemperature ?? 0) + ); + } + + const disabled = isHeaterControlDisabled(status.printerState || 'Unknown'); + panel.querySelectorAll('button[data-c5-action]').forEach((btn) => { + btn.disabled = disabled; + }); +} + +/** + * Show the read-only TVOC air-quality reading for the Creator 5 Pro and hide its + * (non-functional) filtration controls. Other printers keep their filtration + * controls and hide the TVOC block. + */ +export function updateTvocDisplay(status: PrinterStatus | null): void { + const tvocInfo = $('tvoc-info'); + const filtrationSection = $('filtration-section'); + if (!tvocInfo && !filtrationSection) { + return; + } + + const isCreator5Pro = Boolean(state.printerFeatures?.isCreator5Pro); + + filtrationSection?.classList.toggle('hidden', isCreator5Pro); + tvocInfo?.classList.toggle('hidden', !isCreator5Pro); + + if (isCreator5Pro) { + const tvoc = status?.tvocLevel; + setTextContent( + 'tvoc-status', + tvoc !== undefined && !Number.isNaN(tvoc) ? `${Math.round(tvoc)}` : '--' + ); + } } export function updateFiltrationStatus(mode?: 'external' | 'internal' | 'none'): void { diff --git a/src/webui/static/webui.css b/src/webui/static/webui.css index 19b35ec..58f034a 100644 --- a/src/webui/static/webui.css +++ b/src/webui/static/webui.css @@ -537,6 +537,30 @@ body { height: 10px; } +/* Creator 5 multi-tool temperature card */ +.c5-temps-body { + display: flex; + flex-direction: column; + gap: 10px; +} + +.c5-tool-grid { + display: flex; + flex-direction: column; + gap: 10px; +} + +.c5-temps-unavailable { + color: var(--text-muted, var(--text-color)); + font-size: 13px; + opacity: 0.75; +} + +.temp-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + .filtration-section, #tvoc-info { display: flex; diff --git a/src/webui/types/web-api.types.ts b/src/webui/types/web-api.types.ts index c2dd644..6585aa9 100644 --- a/src/webui/types/web-api.types.ts +++ b/src/webui/types/web-api.types.ts @@ -68,6 +68,15 @@ export type WebSocketMessageType = | 'PONG' | 'SPOOLMAN_UPDATE'; +/** + * Per-tool temperature reading for multi-tool printers (Creator 5 series). + * One entry per nozzle; index 0 maps to the printer's T1 in the UI. + */ +export interface ToolTemperatureData { + readonly current: number; + readonly target: number; +} + /** * Represents the detailed status data of a printer. * This unified interface is used across WebSocket messages, API responses, @@ -93,6 +102,13 @@ export interface PrinterStatusData { readonly cumulativePrintTime?: number; // Total lifetime print time in minutes readonly formattedEta?: string; // Firmware ETA string (e.g. "04:48" = 4h48m remaining) readonly elapsedTimeSeconds?: number; // Precise elapsed seconds for HH:MM:SS display + // Creator 5 series (multi-tool) fields. Undefined/empty on single-nozzle printers. + readonly toolTemps?: readonly ToolTemperatureData[]; + readonly chamberTemperature?: number; + readonly chamberTargetTemperature?: number; + readonly hasChamberControl?: boolean; + readonly isCreator5Pro?: boolean; + readonly tvocLevel?: number; } /** @@ -258,6 +274,10 @@ export interface PrinterFeatures { readonly canResume: boolean; readonly canCancel: boolean; readonly ledUsesLegacyAPI?: boolean; // Whether LED control should use legacy G-code commands + /** Multi-tool printer (Creator 5 series) — gates the per-tool temperature card. */ + readonly hasMultiTool?: boolean; + /** Creator 5 Pro — gates the read-only TVOC air-quality display. */ + readonly isCreator5Pro?: boolean; } /**