mcp-3d-printer-server 1.2.13 → 1.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,7 +33,7 @@ function describeOutput(stdoutTail, stderrTail) {
33
33
  * cancellation rejects with a SlicerError that includes the evidence.
34
34
  */
35
35
  export function runSlicerProcess(options) {
36
- const { command, args, timeoutMs, slicerType, isCancelled } = options;
36
+ const { command, args, timeoutMs, slicerType, isCancelled, failureReason } = options;
37
37
  return new Promise((resolve, reject) => {
38
38
  const stdout = new TailBuffer();
39
39
  const stderr = new TailBuffer();
@@ -87,7 +87,8 @@ export function runSlicerProcess(options) {
87
87
  : signal
88
88
  ? `Slicer was terminated by signal ${signal}`
89
89
  : `Slicer exited with code ${code}`;
90
- settle(() => reject(new SlicerError("execution", `${reason} (${slicerType}: ${command}).\n${describeOutput(stdoutTail, stderrTail)}`, {
90
+ const cliReason = timedOut || cancelled ? undefined : failureReason?.(code);
91
+ settle(() => reject(new SlicerError("execution", `${reason} (${slicerType}: ${command})${cliReason ? `: ${cliReason}` : ""}.\n${describeOutput(stdoutTail, stderrTail)}`, {
91
92
  slicerType,
92
93
  slicerPath: command,
93
94
  exitCode: code,
@@ -46,6 +46,14 @@ export declare class STLManipulator extends EventEmitter {
46
46
  private appendOrcaProfileArgs;
47
47
  private resolveOrcaGcodeOutput;
48
48
  private resolveSlicerTimeoutMs;
49
+ /**
50
+ * Apply slice_stl's scale and rotations to a copy of the mesh as OrcaSlicer's
51
+ * CLI would, about the model's centre. Orca adds each --rotate* to the
52
+ * object's Euler angles (ModelVolume::rotate), so the result rotates about X,
53
+ * then Y, then Z whatever the argument order, after scaling. The slicer then
54
+ * places the result on the plate.
55
+ */
56
+ private transformMeshForCli;
49
57
  /**
50
58
  * Load STL file and return geometry and bounding box
51
59
  */
@@ -8,7 +8,7 @@ import { promisify } from 'util';
8
8
  import { EventEmitter } from 'events';
9
9
  import * as crypto from 'crypto';
10
10
  import { detectProfilesRoot } from '../slicer/profile-flatten.js';
11
- import { BambuCliProfilePreparer, assertSlicedProjectOutput, buildBambuCliArgs, } from '../slicer/bambu-cli.js';
11
+ import { BambuCliProfilePreparer, assertSlicedProjectOutput, bambuCliFailureReason, buildBambuCliArgs, planBambuCliInput, } from '../slicer/bambu-cli.js';
12
12
  import { runSlicerProcess } from '../slicer/run-slicer.js';
13
13
  import { SlicerError, asPreparationError } from '../slicer/slicer-error.js';
14
14
  import { scanGcodeThermal } from '../safety/gcode-thermal.js';
@@ -110,6 +110,30 @@ export class STLManipulator extends EventEmitter {
110
110
  }
111
111
  return timeoutMs;
112
112
  }
113
+ /**
114
+ * Apply slice_stl's scale and rotations to a copy of the mesh as OrcaSlicer's
115
+ * CLI would, about the model's centre. Orca adds each --rotate* to the
116
+ * object's Euler angles (ModelVolume::rotate), so the result rotates about X,
117
+ * then Y, then Z whatever the argument order, after scaling. The slicer then
118
+ * places the result on the plate.
119
+ */
120
+ async transformMeshForCli(stlFilePath, options, outputDir) {
121
+ const { geometry, boundingBox } = await this.loadSTL(stlFilePath);
122
+ const center = new THREE.Vector3();
123
+ boundingBox.getCenter(center);
124
+ const radians = (degrees) => degrees * Math.PI / 180;
125
+ geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(-center.x, -center.y, -center.z));
126
+ if (options.scale !== undefined)
127
+ geometry.applyMatrix4(new THREE.Matrix4().makeScale(options.scale, options.scale, options.scale));
128
+ if (options.rotateX)
129
+ geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(radians(options.rotateX)));
130
+ if (options.rotateY)
131
+ geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(radians(options.rotateY)));
132
+ if (options.rotate)
133
+ geometry.applyMatrix4(new THREE.Matrix4().makeRotationZ(radians(options.rotate)));
134
+ geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(center.x, center.y, center.z));
135
+ return this.saveSTL(geometry, path.join(outputDir, `${path.basename(stlFilePath, path.extname(stlFilePath))}_transformed.stl`));
136
+ }
113
137
  /**
114
138
  * Load STL file and return geometry and bounding box
115
139
  */
@@ -806,11 +830,14 @@ export class STLManipulator extends EventEmitter {
806
830
  }
807
831
  options.loadFilaments = legacyFilaments;
808
832
  }
833
+ const cliInput = planBambuCliInput(cliType, is3mf, options);
809
834
  const profilesRoot = detectProfilesRoot(slicerPath, cliType);
810
835
  const preparer = new BambuCliProfilePreparer(path.join(this.tempDir, 'slicer-profiles'));
811
836
  const bundle = await preparer.prepare(stlFilePath, outputBase, cliType, profilesRoot, printerPreset, orcaProfiles.settingsProfiles[0], options);
812
837
  bambuOutputDir = fs.mkdtempSync(path.join(this.tempDir, `${operationId}-bambu-output-`));
813
- args = [...buildBambuCliArgs(bundle, bambuOutputDir, exportName, options), stlFilePath];
838
+ // The transformed copy lives in the run's output directory, which is removed afterwards.
839
+ const inputPath = cliInput.meshTransformed ? await this.transformMeshForCli(stlFilePath, options, bambuOutputDir) : stlFilePath;
840
+ args = [...buildBambuCliArgs(bundle, bambuOutputDir, exportName, options, cliInput), inputPath];
814
841
  bambuExport = {
815
842
  producedPath: path.join(bambuOutputDir, exportName),
816
843
  // One folder per operation: concurrent slices of same-named inputs
@@ -871,12 +898,14 @@ export class STLManipulator extends EventEmitter {
871
898
  if (progressCallback)
872
899
  progressCallback(20, `Executing slicer: ${slicerPath} ${args.join(' ')}`);
873
900
  console.error(`Executing: ${slicerPath} ${args.join(' ')}`);
901
+ const cliOutputDir = bambuOutputDir;
874
902
  await runSlicerProcess({
875
903
  command: slicerPath,
876
904
  args,
877
905
  timeoutMs: this.resolveSlicerTimeoutMs(),
878
906
  slicerType,
879
907
  isCancelled: () => !this.activeOperations.get(operationId),
908
+ failureReason: cliOutputDir ? (exitCode) => bambuCliFailureReason(exitCode, cliOutputDir) : undefined,
880
909
  });
881
910
  if (!this.activeOperations.get(operationId)) {
882
911
  throw new SlicerError('execution', 'Slicing operation cancelled after process finished', { slicerType, slicerPath });
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { STLManipulator } from '../stl/stl-manipulator.js';
3
3
  import fs from 'fs';
4
+ import os from 'os';
4
5
  import path from 'path';
5
6
  import * as THREE from 'three';
6
7
  // Create a test directory
@@ -8,6 +9,8 @@ const TEST_DIR = path.join(process.cwd(), 'test');
8
9
  if (!fs.existsSync(TEST_DIR)) {
9
10
  fs.mkdirSync(TEST_DIR, { recursive: true });
10
11
  }
12
+ // Operation outputs go to a scratch folder removed after the run
13
+ const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stl-manipulator-test-'));
11
14
  // Path for a sample STL file
12
15
  const sampleStlPath = path.join(TEST_DIR, 'sample_cube.stl');
13
16
  // Create a simple cube STL for testing if it doesn't exist
@@ -36,8 +39,8 @@ async function testSTLManipulator() {
36
39
  try {
37
40
  // Create a sample STL for testing
38
41
  const stlPath = await createSampleCube();
39
- // Create an instance of the STL manipulator with the test directory
40
- const manipulator = new STLManipulator(TEST_DIR);
42
+ // Write operation outputs to a scratch folder, not the tracked test directory
43
+ const manipulator = new STLManipulator(outputDir);
41
44
  // Listen for events from the manipulator
42
45
  manipulator.on('operationComplete', (data) => {
43
46
  console.log('Operation complete:', data);
@@ -84,7 +87,10 @@ async function testSTLManipulator() {
84
87
  }
85
88
  catch (error) {
86
89
  console.error('An error occurred during testing:', error);
87
- process.exit(1);
90
+ process.exitCode = 1;
91
+ }
92
+ finally {
93
+ fs.rmSync(outputDir, { recursive: true, force: true });
88
94
  }
89
95
  }
90
96
  // Run the tests
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mcp-3d-printer-server",
3
3
  "mcpName": "io.github.DMontgomery40/mcp-3d-printer-server",
4
- "version": "1.2.13",
4
+ "version": "1.2.15",
5
5
  "description": "MCP server for connecting Claude with 3D printer management systems",
6
6
  "main": "dist/index.js",
7
7
  "repository": "https://github.com/DMontgomery40/mcp-3D-printer-server",
@@ -88,7 +88,7 @@
88
88
  "jszip": "^3.10.2",
89
89
  "three": "^0.173.0",
90
90
  "xml2js": "^0.6.2",
91
- "zod": "^3.25.76"
91
+ "zod": "^4.6.7"
92
92
  },
93
93
  "devDependencies": {
94
94
  "@types/jszip": "^3.4.0",
package/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/DMontgomery40/mcp-3D-printer-server",
8
8
  "source": "github"
9
9
  },
10
- "version": "1.2.13",
10
+ "version": "1.2.15",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "mcp-3d-printer-server",
15
- "version": "1.2.13",
15
+ "version": "1.2.15",
16
16
  "runtimeHint": "npx",
17
17
  "transport": {
18
18
  "type": "stdio"
package/src/fulu-orca.ts CHANGED
@@ -99,6 +99,46 @@ export function isFuluPrintRpcMethod(method: string): boolean {
99
99
  return FULU_PRINT_RPC_METHODS.has(method.trim());
100
100
  }
101
101
 
102
+ const FULU_LINUX_DOCS = "https://github.com/DMontgomery40/mcp-3D-printer-server/blob/main/docs/FULU.md#linux";
103
+
104
+ /**
105
+ * FULU's Linux host opens its network and source libraries by bare name from
106
+ * its working directory instead of PJARCZAK_BAMBU_PLUGIN_DIR
107
+ * (FULU-Foundation/OrcaSlicer-bambulab#51), so name them in that directory
108
+ * unless they are already set. macOS wrappers run the host in a Linux guest
109
+ * that may not see host paths, so they are left alone.
110
+ */
111
+ export function linuxPluginLibraryEnv(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): Record<string, string> {
112
+ const pluginDir = env.PJARCZAK_BAMBU_PLUGIN_DIR?.trim();
113
+ if (platform !== "linux" || !pluginDir) return {};
114
+ const libraries: Record<string, string> = {};
115
+ if (!env.PJARCZAK_BAMBU_NETWORK_SO?.trim()) libraries.PJARCZAK_BAMBU_NETWORK_SO = path.resolve(pluginDir, "libbambu_networking.so");
116
+ if (!env.PJARCZAK_BAMBU_SOURCE_SO?.trim()) libraries.PJARCZAK_BAMBU_SOURCE_SO = path.resolve(pluginDir, "libBambuSource.so");
117
+ return libraries;
118
+ }
119
+
120
+ /** FULU's Linux host exits 101 when it cannot reopen a socket stdout (FULU-Foundation/OrcaSlicer-bambulab#50). */
121
+ const STDOUT_SOCKET_HINT =
122
+ "Exit code 101 means FULU's Linux host could not reopen its stdout, which fails when stdout is a socket, as Node.js provides " +
123
+ "(https://github.com/FULU-Foundation/OrcaSlicer-bambulab/issues/50). Launch the host through a wrapper that pipes its output through cat: " +
124
+ FULU_LINUX_DOCS;
125
+
126
+ /** Actionable hints for a bridge run: the exit-101 socket bug and a host that cannot open the plug-in libraries. */
127
+ function bridgeHints(exitCode: number | null, responses: Record<string, unknown>): string[] {
128
+ const hints: string[] = [];
129
+ if (exitCode === 101) hints.push(STDOUT_SOCKET_HINT);
130
+ const fileOpenFailure = (Object.values(responses) as Array<{ network_status?: unknown; error?: unknown } | null>)
131
+ .find((value) => [value?.network_status, value?.error].some((text) => typeof text === "string" && /file open failed/i.test(text)));
132
+ if (fileOpenFailure) {
133
+ hints.push(
134
+ `FULU BambuNetwork bridge could not open its networking library (${String(fileOpenFailure.network_status ?? fileOpenFailure.error)}). ` +
135
+ "Set PJARCZAK_BAMBU_PLUGIN_DIR to the directory containing libbambu_networking.so and libBambuSource.so, " +
136
+ `or set PJARCZAK_BAMBU_NETWORK_SO and PJARCZAK_BAMBU_SOURCE_SO to their absolute paths: ${FULU_LINUX_DOCS}`
137
+ );
138
+ }
139
+ return hints;
140
+ }
141
+
102
142
  function normalizePlatform(value: string | undefined): SupportedPlatform {
103
143
  const normalized = (value || process.platform).trim().toLowerCase();
104
144
  if (normalized === "mac" || normalized === "macos" || normalized === "darwin") {
@@ -284,7 +324,7 @@ function setupCommands(platform: SupportedPlatform, pluginDir: string, runtimeDi
284
324
  const verify = path.join(pluginDir, "verify_runtime_macos.sh");
285
325
  const bridgeHost = path.join(runtimeDir || "", "pjarczak_bambu_linux_host");
286
326
  const wrapper = path.join(pluginDir, "pjarczak-bambu-linux-host-wrapper");
287
- return [
327
+ const commands = [
288
328
  {
289
329
  label: "install macOS Lima runtime",
290
330
  command: `bash ${quoteShell(install)} -PackageDir ${quoteShell(pluginDir)} -PluginDir ${quoteShell(pluginDir)}`,
@@ -297,11 +337,15 @@ function setupCommands(platform: SupportedPlatform, pluginDir: string, runtimeDi
297
337
  label: "verify macOS Lima runtime core before Linux plugin payload is present",
298
338
  command: `bash ${quoteShell(verify)} -PackageDir ${quoteShell(pluginDir)} -PluginDir ${quoteShell(pluginDir)} -AllowMissingLinuxPlugin`,
299
339
  },
300
- {
340
+ ];
341
+ // The wrapper ships in FULU's macOS bundle; suggest it only where it exists.
342
+ if (fs.existsSync(wrapper)) {
343
+ commands.push({
301
344
  label: "bridge command for FULU BambuNetwork runtime",
302
345
  command: `${quoteShell(wrapper)} ${quoteShell(bridgeHost)}`,
303
- },
304
- ];
346
+ });
347
+ }
348
+ return commands;
305
349
  }
306
350
 
307
351
  if (platform === "win32") {
@@ -336,6 +380,11 @@ function setupCommands(platform: SupportedPlatform, pluginDir: string, runtimeDi
336
380
  label: "configure MCP slicer path",
337
381
  command: "SLICER_TYPE=orcaslicer-bambulab SLICER_PATH=/path/to/orcaslicer",
338
382
  },
383
+ {
384
+ label: "bridge command for FULU's Linux host, piped through cat (FULU #50)",
385
+ command: `FULU_BAMBU_BRIDGE_COMMAND="${quoteShell(path.join(pluginDir, "pjarczak_bambu_linux_host"))} | cat" ` +
386
+ "PJARCZAK_BAMBU_PLUGIN_DIR=\"$HOME/.config/OrcaSlicer/plugins\"",
387
+ },
339
388
  ];
340
389
  }
341
390
 
@@ -350,6 +399,7 @@ function defaultBridgeCommand(platform: SupportedPlatform, pluginDir: string, ru
350
399
 
351
400
  if (platform === "darwin") {
352
401
  const wrapper = path.join(pluginDir, "pjarczak-bambu-linux-host-wrapper");
402
+ if (!fs.existsSync(wrapper)) return undefined;
353
403
  const runtimeHost = path.join(
354
404
  runtimeDir || defaultRuntimeDir(platform) || "",
355
405
  "pjarczak_bambu_linux_host"
@@ -358,8 +408,10 @@ function defaultBridgeCommand(platform: SupportedPlatform, pluginDir: string, ru
358
408
  }
359
409
 
360
410
  if (platform === "linux") {
411
+ // The v1.0.0 host exits 101 on the socket stdout Node.js gives a child
412
+ // (FULU-Foundation/OrcaSlicer-bambulab#50); a shell pipe through cat avoids it.
361
413
  const host = path.join(pluginDir, "pjarczak_bambu_linux_host");
362
- return fs.existsSync(host) ? quoteShell(host) : undefined;
414
+ return fs.existsSync(host) ? `${quoteShell(host)} | cat` : undefined;
363
415
  }
364
416
 
365
417
  return undefined;
@@ -417,6 +469,7 @@ async function runBridgeRequests(
417
469
  return await new Promise((resolve, reject) => {
418
470
  const child = spawn(command, {
419
471
  shell: true,
472
+ env: { ...process.env, ...linuxPluginLibraryEnv(process.env, process.platform) },
420
473
  stdio: ["pipe", "pipe", "pipe"],
421
474
  });
422
475
 
@@ -433,13 +486,35 @@ async function runBridgeRequests(
433
486
  callback();
434
487
  };
435
488
 
489
+ const unanswered = new Set(requests.map((request) => request.id));
436
490
  const timer = setTimeout(() => {
491
+ child.stdin?.end();
437
492
  child.kill("SIGKILL");
438
- settle(() => reject(new Error(`FULU bridge request timed out after ${timeoutMs}ms.`)));
493
+ // Keep what the host did say: a hung host is diagnosed from its stderr and partial answers.
494
+ const responses = Object.fromEntries(parseBridgeFrames(Buffer.concat(stdoutChunks))
495
+ .filter((frame) => frame.type === FRAME_TYPE_JSON_RESPONSE)
496
+ .map((frame) => [String(frame.id), (() => { try { return JSON.parse(frame.payload.toString("utf8")); } catch { return null; } })()]));
497
+ const stderr = Buffer.concat(stderrChunks).toString("utf8").trim().slice(-1000);
498
+ const hints = bridgeHints(null, responses);
499
+ settle(() => reject(new Error(
500
+ `FULU bridge request timed out after ${timeoutMs}ms with ${unanswered.size} of ${requests.length} request(s) unanswered.` +
501
+ (stderr ? ` Host stderr: ${stderr}` : "") + (hints.length ? ` ${hints.join(" ")}` : "")
502
+ )));
439
503
  }, timeoutMs);
440
504
 
441
- child.stdout?.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
505
+ // FULU's host exits on stdin EOF without answering requests still in
506
+ // flight, so stdin closes only once every request has a response.
507
+ child.stdout?.on("data", (chunk: Buffer) => {
508
+ stdoutChunks.push(chunk);
509
+ if (!unanswered.size) return;
510
+ for (const frame of parseBridgeFrames(Buffer.concat(stdoutChunks))) {
511
+ if (frame.type === FRAME_TYPE_JSON_RESPONSE) unanswered.delete(frame.id);
512
+ }
513
+ if (!unanswered.size) child.stdin?.end();
514
+ });
442
515
  child.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
516
+ // A host that exits early (exit code 101) closes the pipe; close reports the result.
517
+ child.stdin?.on("error", () => {});
443
518
  child.on("error", (error) => {
444
519
  settle(() => reject(error));
445
520
  });
@@ -478,7 +553,7 @@ async function runBridgeRequests(
478
553
  for (const request of requests) {
479
554
  child.stdin?.write(writeJsonRequestFrame(request.id, request.method, request.payload));
480
555
  }
481
- child.stdin?.end();
556
+ if (!unanswered.size) child.stdin?.end();
482
557
  });
483
558
  }
484
559
 
@@ -493,9 +568,13 @@ async function probeFuluBridge(command: string, timeoutMs: number): Promise<Reco
493
568
  timeoutMs
494
569
  );
495
570
 
571
+ const handshake = probe.responses["1"] as { ok?: unknown; network_loaded?: unknown } | undefined;
572
+ const hints = bridgeHints(probe.exitCode, probe.responses);
496
573
  return {
497
574
  exitCode: probe.exitCode,
498
- ok: probe.ok && Boolean(probe.responses["1"]),
575
+ // A handshake from a host that could not load the networking plug-in is not a ready bridge.
576
+ ok: probe.ok && Boolean(handshake) && handshake?.ok !== false && handshake?.network_loaded !== false,
577
+ ...(hints.length ? { hints } : {}),
499
578
  stderr: probe.stderr,
500
579
  logs: probe.logs,
501
580
  handshake: probe.responses["1"] || null,
@@ -546,6 +625,7 @@ export async function invokeFuluBridgeRpc(options: FuluBridgeRpcOptions): Promis
546
625
  options.timeoutMs || 5000
547
626
  );
548
627
 
628
+ const hints = bridgeHints(bridgeResult.exitCode, bridgeResult.responses);
549
629
  return {
550
630
  method,
551
631
  readOnly,
@@ -554,6 +634,7 @@ export async function invokeFuluBridgeRpc(options: FuluBridgeRpcOptions): Promis
554
634
  bambuModel: options.bambuModel || undefined,
555
635
  exitCode: bridgeResult.exitCode,
556
636
  ok: bridgeResult.ok,
637
+ ...(hints.length ? { hints } : {}),
557
638
  stderr: bridgeResult.stderr,
558
639
  logs: bridgeResult.logs,
559
640
  response: bridgeResult.responses["1"] || null,
@@ -601,6 +682,12 @@ export async function inspectFuluOrcaSetup(options: FuluOrcaSetupOptions): Promi
601
682
  pluginDir: pluginDir || null,
602
683
  runtimeDir: runtimeDir || null,
603
684
  bridgeCommand: bridgeCommand || null,
685
+ // Linux only: absolute plug-in library paths passed to FULU's host (FULU #51).
686
+ ...(platform === "linux" ? { pluginLibraries: {
687
+ ...Object.fromEntries(["PJARCZAK_BAMBU_NETWORK_SO", "PJARCZAK_BAMBU_SOURCE_SO"]
688
+ .filter((name) => process.env[name]?.trim()).map((name) => [name, process.env[name]!.trim()])),
689
+ ...linuxPluginLibraryEnv(process.env, process.platform),
690
+ } } : {}),
604
691
  fileChecks,
605
692
  missingRequiredFiles,
606
693
  optionalMissingFiles,
@@ -626,7 +713,12 @@ export async function inspectFuluOrcaSetup(options: FuluOrcaSetupOptions): Promi
626
713
  "Bambu print start still enforces BAMBU_MODEL before generating or sending project G-code.",
627
714
  "Raw bridge print methods are disabled; print_3mf inspects the exact plate, checks the live printer and asks for human confirmation.",
628
715
  "The FULU BambuNetwork bridge is a separate runtime from the LAN MQTT/FTPS path; use bridge probing to verify that runtime before relying on cloud behavior.",
629
- "FULU macOS release bundles may ship the bridge dylib and host shims before the Linux plugin .so payload is present; missing linux-plugin entries mean the slicer can still be tested, but the BambuNetwork bridge is not fully ready.",
716
+ ...(platform === "darwin"
717
+ ? ["FULU macOS release bundles may ship the bridge dylib and host shims before the Linux plugin .so payload is present; missing linux-plugin entries mean the slicer can still be tested, but the BambuNetwork bridge is not fully ready."]
718
+ : []),
719
+ ...(platform === "linux"
720
+ ? ["FULU's v1.0.0 Linux host exits 101 when its stdout is a Node.js socket (FULU #50) and opens the plug-in libraries from its working directory (FULU #51). Pipe the host through cat and set PJARCZAK_BAMBU_PLUGIN_DIR; the server then passes the absolute library paths."]
721
+ : []),
630
722
  ],
631
723
  };
632
724
  }
package/src/index.ts CHANGED
@@ -270,11 +270,11 @@ const SLICE_TOOL_PROPERTIES = {
270
270
  description: "Template directory override when resolving template_name (default: BAMBU_TEMPLATE_DIR or ~/Sync/bambu/templates).",
271
271
  },
272
272
  uptodate: { type: "boolean", description: "Bambu-compatible slicing: refresh 3MF preset configs to the installed slicer version (--uptodate)." },
273
- repetitions: { type: "number", description: "Bambu-compatible slicing: print N identical copies (--repetitions)." },
273
+ repetitions: { type: "number", description: "Bambu-compatible slicing: print N identical copies (--repetitions). OrcaSlicer copies an STL with --clone-objects; do not combine with clone_objects." },
274
274
  orient: { type: "boolean", description: "Bambu-compatible slicing: auto-orient for printability (--orient)." },
275
- arrange: { type: "boolean", description: "Bambu-compatible slicing: auto-arrange objects on the plate (--arrange). Set false to keep an existing layout." },
275
+ arrange: { type: "boolean", description: "Bambu-compatible slicing: auto-arrange objects on the plate (--arrange); STL input is arranged by default. Set false to keep an existing layout: an STL that is not already on the plate then fails to slice." },
276
276
  ensure_on_bed: { type: "boolean", description: "Bambu-compatible slicing: lower floating models onto the bed (--ensure-on-bed)." },
277
- clone_objects: { type: "string", description: "Bambu-compatible slicing: comma-separated clone counts per object index, e.g. '1,3,1,10'." },
277
+ clone_objects: { type: "string", description: "Bambu-compatible slicing: clone the loaded model, total copies as a single integer, e.g. '3' (--clone-objects). The Bambu-style CLIs count copies per input file and this tool loads one file, so a comma-separated list is rejected by the slicer." },
278
278
  skip_objects: { type: "string", description: "Bambu-compatible slicing: comma-separated object indices to skip, e.g. '3,5,10'." },
279
279
  enable_timelapse: { type: "boolean", description: "Bambu-compatible slicing: insert timelapse parking moves (--enable-timelapse)." },
280
280
  allow_mix_temp: { type: "boolean", description: "Bambu-compatible slicing: allow filaments with different temperature requirements on one plate." },
@@ -12,6 +12,7 @@ import {
12
12
  manualHeatingRequirements,
13
13
  publishBambuCommand,
14
14
  readFreshPrinterStatus,
15
+ unchippedNozzles,
15
16
  validatePrinterState,
16
17
  type PrinterStateRequirements,
17
18
  } from "../safety/printer-state.js";
@@ -184,11 +185,14 @@ export class BambuImplementation extends PrinterImplementation {
184
185
 
185
186
  async confirmPrintPreflight(serial: string, status: any, inspection: PrintFileInspection): Promise<string | undefined> {
186
187
  const finishedJob = this.finishedJobIdentity(status);
188
+ const unchipped = unchippedNozzles(status?.raw);
187
189
  await this.confirmHardwareAction(
188
190
  `Start a checked print on ${inspection.model.toUpperCase()} (${serial})? Nozzles: ${inspection.nozzleDiameters.join(", ")} mm. ` +
189
191
  `Materials: ${inspection.materials.join(", ")}. Peak targets: nozzle ${inspection.maxNozzleTemperature}°C, bed ${inspection.maxBedTemperature}°C, chamber ${inspection.maxChamberTemperature}°C. ` +
190
192
  `File SHA-256: ${inspection.sha256}. Confirm the physical spool labels and that the build plate is clear.` +
191
- (finishedJob !== undefined ? " The printer reports FINISH: remove the previous part and debris before confirming." : ""),
193
+ (finishedJob !== undefined ? " The printer reports FINISH: remove the previous part and debris before confirming." : "") +
194
+ (unchipped.length ? ` Printer ${unchipped.length === 1 ? `nozzle ${unchipped[0]} reports` : `nozzles ${unchipped.join(" and ")} report`} no identity chip, ` +
195
+ `so ${unchipped.length === 1 ? "its" : "their"} diameter and type are the printer's configured settings; confirm they match the installed ${unchipped.length === 1 ? "hotend" : "hotends"}.` : ""),
192
196
  finishedJob !== undefined
193
197
  );
194
198
  return finishedJob;
@@ -261,8 +265,12 @@ export class BambuImplementation extends PrinterImplementation {
261
265
  console.warn("PushAllCommand failed, continuing with cached status", error);
262
266
  }
263
267
 
264
- if (!printer.data || Object.keys(printer.data).length === 0) {
265
- await new Promise((resolve) => setTimeout(resolve, 1500));
268
+ // The first push after connecting can be a sparse hello (model and
269
+ // modules); the full status follows. Returning before it merges reported
270
+ // status UNKNOWN, so wait for gcode_state within the same 1.5 s bound.
271
+ const deadline = Date.now() + 1500;
272
+ while (typeof printer.data?.gcode_state !== "string" && Date.now() < deadline) {
273
+ await sleep(50);
266
274
  }
267
275
 
268
276
  const data = printer.data;
@@ -50,7 +50,13 @@ export async function requireHumanConfirmation(
50
50
  physicalCheck = false
51
51
  ): Promise<void> {
52
52
  if (!physicalCheck && confirmationOptOut(scope)) return;
53
- if (!confirm || !(await confirm(message))) {
54
- throw new Error("Hardware safety confirmation was declined or unavailable. Use an MCP client with elicitation support. No command was sent.");
53
+ if (!confirm) throw new Error(CONFIRMATION_UNAVAILABLE);
54
+ if (!(await confirm(message))) {
55
+ throw new Error("Hardware safety confirmation was declined or cancelled in the MCP client. No command was sent. " + (physicalCheck
56
+ ? "This confirmation covers a physical check at the printer and always needs a person to accept it."
57
+ : "Some clients decline prompts automatically, for example in a non-interactive permission mode. A deliberately headless " +
58
+ `setup that approves jobs another way can set ${scope === "bambu"
59
+ ? "PRINT_REQUIRE_CONFIRMATION=0 (or BAMBU_REQUIRE_CONFIRMATION=0)"
60
+ : "PRINT_REQUIRE_CONFIRMATION=0"} to skip ordinary print and heating prompts.`));
55
61
  }
56
62
  }
@@ -148,6 +148,43 @@ function nozzleTypeAndFlow(value: unknown): {type?: string; flow?: string} {
148
148
  const type = token(value);
149
149
  return {type: ["stainlesssteel","hardenedsteel","tungstencarbide","brass"].includes(type) ? type : undefined};
150
150
  }
151
+ /**
152
+ * A nozzle without an identity chip reports serial "N/A" and keeps the
153
+ * unreliable bit set permanently (stock H2D HS01 nozzles), so its diameter
154
+ * and type are configured settings, as on single-nozzle printers.
155
+ */
156
+ function hasNoIdentityChip(entry: any): boolean {
157
+ return typeof entry?.sn === "string" && entry.sn.trim().toUpperCase() === "N/A";
158
+ }
159
+
160
+ /** Bit 0 (information unreliable) is accepted only from a nozzle without an identity chip. */
161
+ function unreliableWithoutChip(entry: any, stat: number): boolean {
162
+ return (stat & 1) !== 0 && hasNoIdentityChip(entry);
163
+ }
164
+
165
+ /** Nozzles whose reported diameter and type are configured settings rather than chip readings. */
166
+ export function unchippedNozzles(raw: unknown): number[] {
167
+ const info = (raw as any)?.device?.nozzle?.info;
168
+ if (!Array.isArray(info)) return [];
169
+ return info.flatMap(entry => {
170
+ const index = finiteNumber(entry?.id);
171
+ const stat = finiteNumber(entry?.stat);
172
+ return index !== undefined && Number.isInteger(index) && index >= 0 && index < 16 &&
173
+ stat !== undefined && Number.isInteger(stat) && stat >= 0 && unreliableWithoutChip(entry, stat) ? [index] : [];
174
+ });
175
+ }
176
+
177
+ const loggedUnchippedNozzles = new Set<string>();
178
+ /** Name each unchipped nozzle a check accepted once per printer on stderr, so headless runs still record it. */
179
+ function logUnchippedNozzles(raw: Record<string, any>, serial: string): void {
180
+ for (const index of unchippedNozzles(raw)) {
181
+ const key = `${serial}:${index}`;
182
+ if (loggedUnchippedNozzles.has(key)) continue;
183
+ loggedUnchippedNozzles.add(key);
184
+ console.error(`Printer ${serial || "(unknown serial)"} nozzle ${index} reports no identity chip; its diameter and type are the printer's configured settings.`);
185
+ }
186
+ }
187
+
151
188
  function reportedNozzles(raw: Record<string,any>): ReportedNozzle[] {
152
189
  const info = raw.device?.nozzle?.info;
153
190
  const output: ReportedNozzle[] = [];
@@ -159,8 +196,11 @@ function reportedNozzles(raw: Record<string,any>): ReportedNozzle[] {
159
196
  const diameter = finiteNumber(entry?.diameter);
160
197
  if (index === undefined || !Number.isInteger(index) || index < 0 || diameter === undefined || diameter <= 0) throw new Error("Printer reported malformed nozzle identity or diameter.");
161
198
  if (entry.stat !== undefined) {
199
+ // BambuStudio DevNozzle: bit 0 set means the information is unreliable;
200
+ // bits 1-2 are 0 normal, 1 abnormal, 2 unknown.
162
201
  const stat = finiteNumber(entry.stat);
163
- if (stat === undefined || !Number.isInteger(stat) || stat < 0 || (stat & 7) !== 0) throw new Error(`Printer nozzle ${index} information is unreliable, abnormal or unknown.`);
202
+ if (stat === undefined || !Number.isInteger(stat) || stat < 0 || (stat & 6) !== 0) throw new Error(`Printer nozzle ${index} information is unreliable, abnormal or unknown.`);
203
+ if ((stat & 1) !== 0 && !unreliableWithoutChip(entry, stat)) throw new Error(`Printer nozzle ${index} information is unreliable; refresh the nozzle information on the printer.`);
164
204
  }
165
205
  if (output.some(nozzle => nozzle.index === index)) throw new Error("Printer reported ambiguous duplicate nozzle indices.");
166
206
  output.push({index,diameter,...nozzleTypeAndFlow(entry.type)});
@@ -316,6 +356,7 @@ export function validatePrinterState(status: any, requirements: PrinterStateRequ
316
356
  filaments.push({position,material,trayIndex,nozzleMin,nozzleMax});
317
357
  }
318
358
  }
359
+ if (nozzles.length) logUnchippedNozzles(raw, identity.observedSerial ?? String(status.serial ?? ""));
319
360
  return {model,reportedNozzles:nozzles,filaments};
320
361
  }
321
362