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.
- package/CHANGELOG.md +31 -0
- package/CONTRIBUTORS.md +2 -0
- package/README.md +2 -2
- package/dist/fulu-orca.d.ts +8 -0
- package/dist/fulu-orca.js +107 -10
- package/dist/index.js +3 -3
- package/dist/printers/bambu.js +11 -4
- package/dist/safety/confirmation.js +9 -2
- package/dist/safety/printer-state.d.ts +2 -0
- package/dist/safety/printer-state.js +42 -1
- package/dist/slicer/bambu-cli.d.ts +21 -1
- package/dist/slicer/bambu-cli.js +86 -11
- package/dist/slicer/run-slicer.d.ts +2 -0
- package/dist/slicer/run-slicer.js +3 -2
- package/dist/stl/stl-manipulator.d.ts +8 -0
- package/dist/stl/stl-manipulator.js +31 -2
- package/dist/test/stl-manipulator-test.js +9 -3
- package/package.json +2 -2
- package/server.json +2 -2
- package/src/fulu-orca.ts +102 -10
- package/src/index.ts +3 -3
- package/src/printers/bambu.ts +11 -3
- package/src/safety/confirmation.ts +8 -2
- package/src/safety/printer-state.ts +42 -1
- package/src/slicer/bambu-cli.ts +92 -7
- package/src/slicer/run-slicer.ts +5 -2
- package/src/stl/stl-manipulator.ts +29 -1
- package/src/test/stl-manipulator-test.ts +9 -3
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
40
|
-
const manipulator = new STLManipulator(
|
|
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.
|
|
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.
|
|
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": "^
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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." },
|
package/src/printers/bambu.ts
CHANGED
|
@@ -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
|
-
|
|
265
|
-
|
|
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
|
|
54
|
-
|
|
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 &
|
|
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
|
|