Skip to content

Commit f38db7e

Browse files
GhostTypesclaude
andcommitted
feat(ifs): set an AD5X slot's material/color from a Spoolman spool
Add an IFS material-station view with a "Set from Spoolman" action. The user picks a spool from the existing Spoolman picker; the spool's material/color are snapped to the AD5X's fixed 14-material / 24-color palette client-side, then applied via a new server route that calls the library's configureSlot (msConfig_cmd). - src/webui/static/shared/ifs-palette.ts: pure palette + nearestColor (CIEDE2000) + nearestMaterial, with a Jest spec. - POST /spoolman/slot-config: zod-validated, material-station gated, resolves the FiveMClient and calls control.configureSlot. Keeps the slot's current material when the spool material doesn't resolve. - Reuses the Spoolman selection modal as the picker. - jest.config.js: map ".js" ESM specifiers to ".ts" so static-module specs run under ts-jest. Bumps @ghosttypes/ff-api to ^1.3.2 for configureSlot/SlotAction. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0baf63b commit f38db7e

16 files changed

Lines changed: 892 additions & 7 deletions

File tree

jest.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ module.exports = {
2828
coverageReporters: ['text', 'lcov', 'html'],
2929
moduleNameMapper: {
3030
'^@/(.*)$': '<rootDir>/src/$1',
31+
// Static browser modules use explicit `.js` ESM specifiers; map them back to
32+
// the `.ts` sources so ts-jest can resolve them under test.
33+
'^(\\.{1,2}/.*)\\.js$': '$1',
3134
},
3235
moduleFileExtensions: ['ts', 'js', 'json'],
3336
verbose: true,

package-lock.json

Lines changed: 23 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@
7171
"author": "Parallel-7",
7272
"license": "MIT",
7373
"dependencies": {
74-
"@ghosttypes/ff-api": "^1.3.0",
74+
"@ghosttypes/ff-api": "^1.3.2",
7575
"@parallel-7/slicer-meta": "1.1.0-20251121155836",
7676
"axios": "^1.8.4",
7777
"express": "^5.1.0",

src/types/polling.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ export interface FiltrationStatus {
128128
export interface PrinterSettings {
129129
nozzleSize?: number; // mm (e.g. 0.4, 0.6) - undefined for legacy printers
130130
filamentType?: string; // PLA, ABS, etc - undefined for legacy printers
131-
speedOffset?: number; // percentage 50-200 - undefined for legacy printers
131+
speedOffset?: number; // percentage 0-1000 (AD5X reports up to 500) - undefined for legacy printers
132132
zAxisOffset?: number; // mm offset value - undefined for legacy printers
133133
}
134134

src/webui/schemas/web-api.schemas.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,30 @@ export const SpoolClearRequestSchema = z.object({
294294
contextId: z.string().optional(),
295295
});
296296

297+
/**
298+
* Slot-config request validation (configure an AD5X material-station slot).
299+
*
300+
* The client snaps a Spoolman spool's material/color to the printer's fixed
301+
* 14-material / 24-color palette and sends the already-snapped values here. Slot
302+
* is 1-based (1-4); `materialName` is null when the spool's material did not
303+
* resolve, in which case the slot keeps its current material. `colorHex` is a
304+
* 3/6/8-digit hex (with or without leading '#').
305+
*/
306+
export const SlotConfigRequestSchema = z.object({
307+
contextId: z.string().optional(),
308+
slot: z
309+
.number()
310+
.int('slot must be an integer')
311+
.min(1, 'slot must be at least 1')
312+
.max(4, 'slot must be at most 4'),
313+
materialName: z.string().min(1).nullable(),
314+
colorHex: z
315+
.string()
316+
.regex(/^#?[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3}(?:[0-9a-fA-F]{2})?)?$/, 'Invalid hex color'),
317+
/** Optional fallback material to keep when `materialName` is null. */
318+
currentMaterial: z.string().min(1).nullable().optional(),
319+
});
320+
297321
// ============================================================================
298322
// TYPE EXPORTS
299323
// ============================================================================
@@ -306,3 +330,4 @@ export type ValidatedPrinterCommand = z.infer<typeof PrinterCommandSchema>;
306330
export type ValidatedPrinterFeatures = z.infer<typeof PrinterFeaturesSchema>;
307331
export type ValidatedSpoolSelectRequest = z.infer<typeof SpoolSelectRequestSchema>;
308332
export type ValidatedSpoolClearRequest = z.infer<typeof SpoolClearRequestSchema>;
333+
export type ValidatedSlotConfigRequest = z.infer<typeof SlotConfigRequestSchema>;

src/webui/server/routes/spoolman-routes.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@
22
* @fileoverview Spoolman integration routes (config, search, active spool management).
33
*/
44

5+
import { FiveMClient } from '@ghosttypes/ff-api';
56
import type { Response, Router } from 'express';
67
import { toAppError } from '../../../utils/error.utils';
78
import {
89
createValidationError,
10+
SlotConfigRequestSchema,
911
SpoolClearRequestSchema,
1012
SpoolSelectRequestSchema,
1113
} from '../../schemas/web-api.schemas';
1214
import type {
1315
ActiveSpoolResponse,
16+
SlotConfigResponse,
1417
SpoolmanConfigResponse,
1518
SpoolSearchResponse,
1619
SpoolSelectResponse,
@@ -88,6 +91,8 @@ export function registerSpoolmanRoutes(router: Router, deps: RouteDependencies):
8891
vendor: spool.filament.vendor?.name || null,
8992
material: spool.filament.material || null,
9093
colorHex: spool.filament.color_hex || '#808080',
94+
rawColorHex: spool.filament.color_hex || null,
95+
multiColorHexes: spool.filament.multi_color_hexes || null,
9196
remainingWeight: spool.remaining_weight || 0,
9297
remainingLength: spool.remaining_length || 0,
9398
archived: spool.archived,
@@ -215,4 +220,77 @@ export function registerSpoolmanRoutes(router: Router, deps: RouteDependencies):
215220
return sendErrorResponse<StandardAPIResponse>(res, 500, appError.message);
216221
}
217222
});
223+
224+
router.post('/spoolman/slot-config', async (req: AuthenticatedRequest, res: Response) => {
225+
try {
226+
const validation = SlotConfigRequestSchema.safeParse(req.body);
227+
if (!validation.success) {
228+
const validationError = createValidationError(validation.error);
229+
return sendErrorResponse<SlotConfigResponse>(res, 400, validationError.error);
230+
}
231+
232+
const { contextId, slot, materialName, colorHex, currentMaterial } = validation.data;
233+
const overrideContextId = contextId || null;
234+
235+
const contextResult = resolveContext(req, deps, {
236+
overrideContextId,
237+
requireBackendReady: true,
238+
requireBackendInstance: true,
239+
});
240+
if (!contextResult.success) {
241+
return sendErrorResponse<SlotConfigResponse>(
242+
res,
243+
contextResult.statusCode,
244+
contextResult.error
245+
);
246+
}
247+
248+
const { contextId: resolvedContextId, backend } = contextResult;
249+
if (!backend) {
250+
return sendErrorResponse<SlotConfigResponse>(res, 503, 'Backend not available');
251+
}
252+
253+
if (!deps.backendManager.isFeatureAvailable(resolvedContextId, 'material-station')) {
254+
return sendErrorResponse<SlotConfigResponse>(
255+
res,
256+
400,
257+
'Material station not available on this printer'
258+
);
259+
}
260+
261+
// Resolve the material to write: the client snaps the spool material to the
262+
// fixed palette; when it does not resolve, keep the slot's current material.
263+
const materialToWrite = materialName ?? currentMaterial ?? null;
264+
if (!materialToWrite) {
265+
return sendErrorResponse<SlotConfigResponse>(
266+
res,
267+
400,
268+
'Spool material did not match a known material and the slot has no current material to keep'
269+
);
270+
}
271+
272+
const primaryClient = backend.getPrimaryClient();
273+
if (!(primaryClient instanceof FiveMClient)) {
274+
return sendErrorResponse<SlotConfigResponse>(
275+
res,
276+
400,
277+
'Material station control requires new API client'
278+
);
279+
}
280+
281+
const result = await primaryClient.control.configureSlot(slot, materialToWrite, colorHex);
282+
const response: SlotConfigResponse = {
283+
success: result,
284+
slot,
285+
material: materialToWrite,
286+
colorHex,
287+
message: result ? `Slot ${slot} updated` : undefined,
288+
error: result ? undefined : 'Failed to configure slot',
289+
};
290+
return res.status(result ? 200 : 500).json(response);
291+
} catch (error) {
292+
const appError = toAppError(error);
293+
return sendErrorResponse<SlotConfigResponse>(res, 500, appError.message);
294+
}
295+
});
218296
}

src/webui/static/app.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import {
6262
confirmMaterialMatching,
6363
setupMaterialMatchingHandlers,
6464
} from './features/material-matching.js';
65+
import { setupIfsStationHandlers } from './features/ifs-station.js';
6566
import { initializeDiscovery } from './features/printer-discovery.js';
6667
import { loadSpoolmanConfig, setupSpoolmanHandlers } from './features/spoolman.js';
6768
import { $, hideElement, showElement } from './shared/dom.js';
@@ -272,6 +273,8 @@ export interface SpoolSummary {
272273
readonly vendor: string | null;
273274
readonly material: string | null;
274275
readonly colorHex: string;
276+
readonly rawColorHex: string | null;
277+
readonly multiColorHexes: string | null;
275278
readonly remainingWeight: number;
276279
readonly remainingLength: number;
277280
readonly archived: boolean;
@@ -297,6 +300,12 @@ export interface SpoolSelectResponse extends ApiResponse {
297300
spool: ActiveSpoolData;
298301
}
299302

303+
export interface SlotConfigResponse extends ApiResponse {
304+
slot: number;
305+
material: string;
306+
colorHex: string;
307+
}
308+
300309
// ============================================================================
301310
// GRID AND SETTINGS MANAGEMENT
302311
// ============================================================================
@@ -365,6 +374,7 @@ async function initialize(): Promise<void> {
365374
setupJobControlEventHandlers();
366375
setupMaterialMatchingHandlers();
367376
setupSpoolmanHandlers();
377+
setupIfsStationHandlers();
368378
initializeDiscovery();
369379

370380
const contextHandlers = {

0 commit comments

Comments
 (0)