mcp-3d-printer-server 1.2.25 → 1.2.26

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.
Files changed (93) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/CONTRIBUTORS.md +5 -1
  3. package/README.md +64 -29
  4. package/dist/ams-mapping.d.ts +38 -0
  5. package/dist/ams-mapping.js +125 -0
  6. package/dist/bambu-network-bridge.d.ts +200 -0
  7. package/dist/bambu-network-bridge.js +881 -0
  8. package/dist/bambu-network-certs.d.ts +13 -0
  9. package/dist/bambu-network-certs.js +152 -0
  10. package/dist/control-reply.d.ts +21 -0
  11. package/dist/control-reply.js +44 -0
  12. package/dist/counterpart/camera-sources.d.ts +16 -0
  13. package/dist/counterpart/camera-sources.js +138 -0
  14. package/dist/counterpart/camera-stream.d.ts +66 -0
  15. package/dist/counterpart/camera-stream.js +246 -0
  16. package/dist/counterpart/camera-tools.d.ts +247 -0
  17. package/dist/counterpart/camera-tools.js +35 -0
  18. package/dist/counterpart/contract.d.ts +21 -5
  19. package/dist/counterpart/contract.js +8 -2
  20. package/dist/counterpart/route.d.ts +31 -0
  21. package/dist/counterpart/route.js +70 -0
  22. package/dist/counterpart/service.d.ts +60 -4
  23. package/dist/counterpart/service.js +142 -16
  24. package/dist/design-guidance.d.ts +26 -0
  25. package/dist/design-guidance.js +177 -0
  26. package/dist/fulu-orca.d.ts +7 -0
  27. package/dist/fulu-orca.js +18 -2
  28. package/dist/index.js +512 -62
  29. package/dist/printers/bambu-filaments.d.ts +68 -0
  30. package/dist/printers/bambu-filaments.js +262 -0
  31. package/dist/printers/bambu.d.ts +99 -6
  32. package/dist/printers/bambu.js +478 -72
  33. package/dist/printers/creality.d.ts +2 -1
  34. package/dist/printers/creality.js +5 -1
  35. package/dist/printers/generic-base.d.ts +11 -6
  36. package/dist/printers/generic-base.js +21 -25
  37. package/dist/printers/klipper.d.ts +2 -1
  38. package/dist/printers/klipper.js +10 -7
  39. package/dist/printers/octoprint.d.ts +2 -1
  40. package/dist/printers/octoprint.js +15 -8
  41. package/dist/printers/printer-factory.d.ts +1 -1
  42. package/dist/printers/printer-factory.js +3 -3
  43. package/dist/printers/prusa.js +0 -1
  44. package/dist/safety/confirmation.d.ts +11 -16
  45. package/dist/safety/confirmation.js +8 -32
  46. package/dist/safety/hidden-hms.d.ts +32 -0
  47. package/dist/safety/hidden-hms.js +405 -0
  48. package/dist/safety/limits.d.ts +8 -2
  49. package/dist/safety/limits.js +14 -5
  50. package/dist/safety/print-file.d.ts +5 -0
  51. package/dist/safety/print-file.js +156 -19
  52. package/dist/safety/printer-state.d.ts +59 -8
  53. package/dist/safety/printer-state.js +174 -39
  54. package/dist/slicer/bambu-cli.d.ts +2 -0
  55. package/dist/slicer/bambu-cli.js +7 -5
  56. package/dist/slicer/layer-pauses.d.ts +47 -0
  57. package/dist/slicer/layer-pauses.js +130 -0
  58. package/dist/stl/stl-manipulator.d.ts +1 -0
  59. package/dist/stl/stl-manipulator.js +40 -0
  60. package/dist/studio-print-options.d.ts +57 -0
  61. package/dist/studio-print-options.js +179 -0
  62. package/package.json +25 -4
  63. package/server.json +2 -2
  64. package/src/ams-mapping.ts +132 -0
  65. package/src/bambu-network-bridge.ts +1075 -0
  66. package/src/bambu-network-certs.ts +154 -0
  67. package/src/control-reply.ts +41 -0
  68. package/src/counterpart/camera-sources.ts +102 -0
  69. package/src/counterpart/camera-stream.ts +195 -0
  70. package/src/counterpart/camera-tools.ts +35 -0
  71. package/src/counterpart/contract.ts +12 -4
  72. package/src/counterpart/route.ts +82 -0
  73. package/src/counterpart/service.ts +150 -16
  74. package/src/design-guidance.ts +188 -0
  75. package/src/fulu-orca.ts +18 -2
  76. package/src/index.ts +526 -75
  77. package/src/printers/bambu-filaments.ts +346 -0
  78. package/src/printers/bambu.ts +506 -81
  79. package/src/printers/creality.ts +8 -3
  80. package/src/printers/generic-base.ts +33 -40
  81. package/src/printers/klipper.ts +12 -8
  82. package/src/printers/octoprint.ts +16 -9
  83. package/src/printers/printer-factory.ts +3 -3
  84. package/src/printers/prusa.ts +0 -1
  85. package/src/safety/confirmation.ts +15 -38
  86. package/src/safety/hidden-hms.ts +406 -0
  87. package/src/safety/limits.ts +15 -5
  88. package/src/safety/print-file.ts +139 -17
  89. package/src/safety/printer-state.ts +179 -40
  90. package/src/slicer/bambu-cli.ts +8 -5
  91. package/src/slicer/layer-pauses.ts +143 -0
  92. package/src/stl/stl-manipulator.ts +45 -0
  93. package/src/studio-print-options.ts +201 -0
@@ -49,6 +49,8 @@ export interface BambuSliceOptions {
49
49
  minSave?: boolean; // --min-save
50
50
  skipModifiedGcodes?: boolean;// --skip-modified-gcodes
51
51
  slicePlate?: number; // --slice N (0 = all plates)
52
+ pauseAtLayers?: number[]; // pause before each 1-based layer, through the slicer's own custom G-code
53
+ customGcodesFile?: string; // internal --load-custom-gcodes file, prepared by sliceSTLWithResult
52
54
  /** Process keys written over the resolved process preset (layer_height, wall_loops, sparse_infill_density). */
53
55
  processOverrides?: Record<string, string>;
54
56
  }
@@ -597,6 +599,9 @@ export interface BambuCliInput {
597
599
  */
598
600
  export function planBambuCliInput(slicerType: BambuCliSlicerType, is3mf: boolean, options: BambuSliceOptions): BambuCliInput {
599
601
  const input = { slicerType, is3mf, meshTransformed: false };
602
+ if (!is3mf && options.repetitions !== undefined && options.cloneObjects) {
603
+ throw new Error("Use either repetitions or clone_objects for an STL, not both.");
604
+ }
600
605
  if (slicerType === "bambustudio") return input;
601
606
  const tilts = !!options.rotateX || !!options.rotateY;
602
607
  if (is3mf) {
@@ -608,9 +613,6 @@ export function planBambuCliInput(slicerType: BambuCliSlicerType, is3mf: boolean
608
613
  }
609
614
  return input;
610
615
  }
611
- if (options.repetitions !== undefined && options.cloneObjects) {
612
- throw new Error("Use either repetitions or clone_objects for an STL with OrcaSlicer, not both.");
613
- }
614
616
  return { ...input, meshTransformed: options.scale !== undefined || tilts };
615
617
  }
616
618
 
@@ -689,8 +691,8 @@ export function buildBambuCliArgs(
689
691
  if (options.orient !== undefined) args.push("--orient", options.orient ? "1" : "0");
690
692
  if (options.arrange !== undefined) args.push("--arrange", options.arrange ? "1" : "0");
691
693
  if (options.repetitions !== undefined) {
692
- // OrcaSlicer rejects --repetitions for one STL; it copies the loaded model with --clone-objects.
693
- if (input.slicerType !== "bambustudio" && !input.is3mf) args.push("--clone-objects", String(options.repetitions));
694
+ // Both Bambu Studio and OrcaSlicer copy standalone STL objects with --clone-objects.
695
+ if (!input.is3mf) args.push("--clone-objects", String(options.repetitions));
694
696
  else args.push("--repetitions", String(options.repetitions));
695
697
  }
696
698
  if (!input.meshTransformed) {
@@ -706,6 +708,7 @@ export function buildBambuCliArgs(
706
708
  args.push("--load-defaultfila");
707
709
  }
708
710
  if (options.loadFilamentIds) args.push("--load-filament-ids", options.loadFilamentIds);
711
+ if (options.customGcodesFile) args.push("--load-custom-gcodes", path.resolve(options.customGcodesFile));
709
712
  return args;
710
713
  }
711
714
 
@@ -0,0 +1,143 @@
1
+ import fs from "node:fs";
2
+ import JSZip from "jszip";
3
+
4
+ /**
5
+ * Pause-at-layer for Bambu-compatible slicer CLIs ("Add pause" in Bambu Studio
6
+ * and OrcaSlicer). The slicer writes the machine's own machine_pause_gcode
7
+ * (M400 U1 on Bambu printers) before the chosen layer, so the printer pauses
8
+ * by itself there and waits for Resume; no remote command is involved.
9
+ *
10
+ * The CLI takes these as --load-custom-gcodes <json> (CustomGCode::Info). It
11
+ * does not apply the pauses a project 3MF already carries in
12
+ * Metadata/custom_gcode_per_print_z.xml, so those are read here and passed the
13
+ * same way. A pause's print_z must be the exact top of its layer: a height
14
+ * between layers lands on the layer below.
15
+ */
16
+
17
+ /** CustomGCode::Type, in the order bbs_3mf.cpp writes it as an integer. */
18
+ const CUSTOM_GCODE_TYPES = ["ColorChange", "PausePrint", "ToolChange", "Template", "Custom", "Unknown"] as const;
19
+ export type CustomGcodeType = (typeof CUSTOM_GCODE_TYPES)[number];
20
+
21
+ export interface CustomGcodeItem {
22
+ type: CustomGcodeType;
23
+ print_z: number;
24
+ color: string;
25
+ extruder: number;
26
+ extra: string;
27
+ }
28
+
29
+ export interface CustomGcodeInfo {
30
+ mode: string;
31
+ gcodes: CustomGcodeItem[];
32
+ }
33
+
34
+ /** 1-based layer numbers, as Bambu Studio's and OrcaSlicer's preview slider count them. */
35
+ export function parsePauseLayers(value: unknown): number[] | undefined {
36
+ if (value === undefined || value === null || value === "") return undefined;
37
+ const raw = Array.isArray(value) ? value : String(value).split(/[\s,;]+/).filter(Boolean);
38
+ const layers = raw.map(entry => Number(entry));
39
+ for (const layer of layers) {
40
+ if (!Number.isInteger(layer) || layer < 2) {
41
+ throw new Error(`pause_at_layers takes whole layer numbers of 2 or more (the printer cannot pause before layer 1), not ${JSON.stringify(value)}.`);
42
+ }
43
+ }
44
+ const unique = [...new Set(layers)].sort((a, b) => a - b);
45
+ return unique.length ? unique : undefined;
46
+ }
47
+
48
+ function xmlAttr(tag: string, name: string): string | undefined {
49
+ const match = new RegExp(`\\b${name}="([^"]*)"`).exec(tag);
50
+ return match ? match[1].replace(/&quot;/g, '"').replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&") : undefined;
51
+ }
52
+
53
+ /**
54
+ * The custom G-codes (pauses, colour changes, ...) a project 3MF stores for
55
+ * one plate (1-based), as Bambu Studio and OrcaSlicer save them.
56
+ */
57
+ export async function readProjectCustomGcodes(threeMfPath: string, plate = 1): Promise<CustomGcodeInfo | undefined> {
58
+ let zip: JSZip;
59
+ try {
60
+ zip = await JSZip.loadAsync(await fs.promises.readFile(threeMfPath));
61
+ } catch {
62
+ return undefined;
63
+ }
64
+ const file = zip.file(/^Metadata\/custom_gcode_per_print_z\.xml$/i)[0];
65
+ if (!file) return undefined;
66
+ const xml = await file.async("string");
67
+ for (const block of xml.match(/<plate>[\s\S]*?<\/plate>/g) ?? []) {
68
+ const info = /<plate_info\b[^>]*>/.exec(block)?.[0];
69
+ if (Number(info ? xmlAttr(info, "id") : NaN) !== plate) continue;
70
+ const gcodes: CustomGcodeItem[] = [];
71
+ for (const tag of block.match(/<layer\b[^>]*>/g) ?? []) {
72
+ const type = CUSTOM_GCODE_TYPES[Number(xmlAttr(tag, "type"))] ?? "Unknown";
73
+ const printZ = Number(xmlAttr(tag, "top_z"));
74
+ if (!Number.isFinite(printZ)) continue;
75
+ gcodes.push({
76
+ type,
77
+ print_z: printZ,
78
+ color: xmlAttr(tag, "color") ?? "",
79
+ extruder: Number(xmlAttr(tag, "extruder") ?? 1) || 1,
80
+ // Template and Custom keep their G-code text in "extra" (CustomGCode.hpp).
81
+ extra: xmlAttr(tag, "extra") ?? "",
82
+ });
83
+ }
84
+ const modeTag = /<mode\b[^>]*>/.exec(block)?.[0];
85
+ return { mode: (modeTag && xmlAttr(modeTag, "value")) || "SingleExtruder", gcodes };
86
+ }
87
+ return undefined;
88
+ }
89
+
90
+ /** The plate's G-code text inside a sliced 3MF. */
91
+ export async function readPlateGcode(threeMfPath: string, plate = 1): Promise<string | undefined> {
92
+ const zip = await JSZip.loadAsync(await fs.promises.readFile(threeMfPath));
93
+ const exact = zip.file(`Metadata/plate_${plate}.gcode`);
94
+ const file = exact ?? zip.file(/^Metadata\/plate_\d+\.gcode$/)[0];
95
+ return file ? file.async("string") : undefined;
96
+ }
97
+
98
+ /** Each layer's top Z in order, from the slicer's "; Z_HEIGHT:" markers. */
99
+ export function layerTops(gcode: string): number[] {
100
+ const tops: number[] = [];
101
+ for (const match of gcode.matchAll(/^; Z_HEIGHT: ([0-9.]+)\s*$/gm)) tops.push(Number(match[1]));
102
+ return tops;
103
+ }
104
+
105
+ /**
106
+ * The 1-based layers whose block holds a pause: the slicer's "; PAUSE_PRINTING"
107
+ * marker or the machine's pause G-code on its own line.
108
+ */
109
+ export function pausedLayers(gcode: string, pauseGcode = "M400 U1"): number[] {
110
+ const lines = gcode.split(/\r?\n/);
111
+ const paused = new Set<number>();
112
+ let layer = 0;
113
+ const pauseLine = pauseGcode.trim();
114
+ for (const line of lines) {
115
+ if (line.startsWith("; Z_HEIGHT:")) { layer += 1; continue; }
116
+ if (layer === 0) continue;
117
+ const trimmed = line.trim();
118
+ if (trimmed === "; PAUSE_PRINTING" || (pauseLine && trimmed === pauseLine)) paused.add(layer);
119
+ }
120
+ return [...paused].sort((a, b) => a - b);
121
+ }
122
+
123
+ /** The machine_pause_gcode a sliced file was made with (its config block), else Bambu's M400 U1. */
124
+ export function machinePauseGcode(gcode: string): string {
125
+ const match = /^; machine_pause_gcode = (.*)$/m.exec(gcode);
126
+ const value = match?.[1]?.split("\\n")[0]?.trim();
127
+ return value || "M400 U1";
128
+ }
129
+
130
+ /** Project custom G-codes plus a pause before each requested layer, at that layer's exact top. */
131
+ export function customGcodesWithPauses(tops: number[], layers: number[], project?: CustomGcodeInfo): CustomGcodeInfo {
132
+ const total = tops.length;
133
+ const beyond = layers.filter(layer => layer > total);
134
+ if (beyond.length) {
135
+ throw new Error(`This print has ${total} layers, so it cannot pause before layer ${beyond.join(", ")}.`);
136
+ }
137
+ const pauses: CustomGcodeItem[] = layers.map(layer => ({ type: "PausePrint", print_z: tops[layer - 1], color: "", extruder: 1, extra: "" }));
138
+ const kept = (project?.gcodes ?? []).filter(item => !(item.type === "PausePrint" && pauses.some(p => Math.abs(p.print_z - item.print_z) < 1e-4)));
139
+ return {
140
+ mode: project?.mode || "SingleExtruder",
141
+ gcodes: [...kept, ...pauses].sort((a, b) => a.print_z - b.print_z),
142
+ };
143
+ }
@@ -21,6 +21,7 @@ import {
21
21
  } from '../slicer/bambu-cli.js';
22
22
  import { runSlicerProcess } from '../slicer/run-slicer.js';
23
23
  import { SlicerError, asPreparationError } from '../slicer/slicer-error.js';
24
+ import { customGcodesWithPauses, layerTops, machinePauseGcode, pausedLayers, readPlateGcode, readProjectCustomGcodes } from '../slicer/layer-pauses.js';
24
25
 
25
26
  export type { BambuSliceOptions } from '../slicer/bambu-cli.js';
26
27
 
@@ -1120,6 +1121,50 @@ export class STLManipulator extends EventEmitter {
1120
1121
  filamentProfile?: string,
1121
1122
  bambuOptions?: BambuSliceOptions,
1122
1123
  processOverrides?: ProcessOverrides
1124
+ ): Promise<SliceOutput> {
1125
+ const once = (options?: BambuSliceOptions) => this.sliceSTLOnce(stlFilePath, slicerType, slicerPath,
1126
+ slicerProfile, progressCallback, printerPreset, filamentProfile, options, processOverrides);
1127
+ const layers = bambuOptions?.pauseAtLayers?.length ? bambuOptions.pauseAtLayers : undefined;
1128
+ const cli = STLManipulator.usesBambuCliPreparation(slicerType, printerPreset);
1129
+ const plate = bambuOptions?.slicePlate && bambuOptions.slicePlate > 0 ? bambuOptions.slicePlate : 1;
1130
+ const project = cli && /\.3mf$/i.test(stlFilePath) ? await readProjectCustomGcodes(stlFilePath, plate) : undefined;
1131
+ if (!layers && !project?.gcodes.length) return once(bambuOptions);
1132
+ if (layers && !cli) throw new Error(`Pausing at a layer needs the Bambu Studio or OrcaSlicer CLI, not ${slicerType}.`);
1133
+ const customGcodesFile = path.join(this.tempDir, `custom-gcodes-${crypto.randomUUID()}.json`);
1134
+ try {
1135
+ let tops: number[] = [];
1136
+ if (layers) {
1137
+ if (progressCallback) progressCallback(10, `Finding layer heights for the pause before layer ${layers.join(", ")}`);
1138
+ const plain = await once({ ...bambuOptions, pauseAtLayers: undefined, customGcodesFile: undefined });
1139
+ const gcode = await readPlateGcode(plain.path, plate);
1140
+ if (!gcode) throw new Error('The slicer wrote no plate G-code, so the pause layers cannot be placed.');
1141
+ tops = layerTops(gcode);
1142
+ }
1143
+ const info = layers ? customGcodesWithPauses(tops, layers, project) : project!;
1144
+ await fs.promises.writeFile(customGcodesFile, JSON.stringify(info));
1145
+ const result = await once({ ...bambuOptions, customGcodesFile });
1146
+ if (layers) {
1147
+ const gcode = await readPlateGcode(result.path, plate);
1148
+ const placed = gcode ? pausedLayers(gcode, machinePauseGcode(gcode)) : [];
1149
+ const missing = layers.filter(layer => !placed.includes(layer));
1150
+ if (!gcode || missing.length || layerTops(gcode).length !== tops.length) {
1151
+ throw new Error(`The slicer did not place the pause before layer ${(missing.length ? missing : layers).join(", ")}. Nothing was sent to the printer.`);
1152
+ }
1153
+ }
1154
+ return result;
1155
+ } finally { await fs.promises.rm(customGcodesFile, { force: true }); }
1156
+ }
1157
+
1158
+ private async sliceSTLOnce(
1159
+ stlFilePath: string,
1160
+ slicerType: SlicerType,
1161
+ slicerPath: string,
1162
+ slicerProfile?: string,
1163
+ progressCallback?: ProgressCallback,
1164
+ printerPreset?: string,
1165
+ filamentProfile?: string,
1166
+ bambuOptions?: BambuSliceOptions,
1167
+ processOverrides?: ProcessOverrides
1123
1168
  ): Promise<SliceOutput> {
1124
1169
  const operationId = this.generateOperationId();
1125
1170
  this.activeOperations.set(operationId, true);
@@ -0,0 +1,201 @@
1
+ /**
2
+ * The print options Bambu Studio's Send dialog sends, resolved the way it
3
+ * resolves them, so every print route sends what Bambu's own Print button
4
+ * would. Source: Bambu Studio v02.08.02.61 (commit 926a7192574b), the version
5
+ * the hidden-HMS and stage tables are pinned to:
6
+ *
7
+ * - SelectMachine.cpp:3344-3356 `set_print_config(bed_type,
8
+ * bed_leveling == "on", flow_cali == "on", false, timelapse, true,
9
+ * ext_change_assist, bed_leveling.getValueInt(), flow_cali.getValueInt(),
10
+ * nozzle_offset_cali.getValueInt(), pa_value_switch ? 0 : 1)`. Vibration
11
+ * calibration is always false and layer inspection always true; the
12
+ * parameter order is PrintJob.hpp:98-116.
13
+ * - SelectMachine.cpp:7318-7329 `getValueInt`: off 0, on 1, auto 2.
14
+ * - SelectMachine.cpp:1117-1123 `update_select_layout`: update_option_opts
15
+ * (which choices each option offers on this printer) runs before
16
+ * load_option_vals.
17
+ * - SelectMachine.cpp:2993-3027 `load_option_vals`: the value saved in
18
+ * BambuStudio.conf under the printer type, if the option offers it; else
19
+ * "auto" if offered; else "on". Dual-nozzle printers then set nozzle offset
20
+ * calibration to "auto" for a two-nozzle job and, without a filament
21
+ * switcher, "off" for a one-nozzle job.
22
+ * - SelectMachine.cpp:2586-2615 `update_option_opts`: bed leveling offers
23
+ * auto/on/off when the printer's support_bed_leveling is 2 and on/off when it
24
+ * is 1; with 0 it is hidden and keeps the auto/on/off it was built with
25
+ * (SelectMachine.cpp:676-691), as does flow calibration when the printer has
26
+ * no PA calibration. DeviceManager.cpp:1100/4441 give P-series printers
27
+ * (series_p1p: P1P, P1S) no PA calibration, so their flow calibration is
28
+ * hidden and resolves to "auto". Nozzle offset calibration always offers auto.
29
+ * - SelectMachine.cpp:705-707: the PA value switch starts on and is not saved,
30
+ * so extruder_cali_manual_mode is 0.
31
+ *
32
+ * Approximations, outside the P1P/P1S: flow calibration on other printers is
33
+ * shown when the printer's push status reports PA calibration (assumed here),
34
+ * and offers auto when support_auto_flow_calibration is true and the mapped
35
+ * filaments allow it (can_support_pa_auto_cali, assumed true). A filament
36
+ * switcher on a dual-nozzle printer is assumed absent.
37
+ */
38
+ import fs from "node:fs";
39
+ import os from "node:os";
40
+ import path from "node:path";
41
+
42
+ export type StudioOptionValue = "off" | "on" | "auto";
43
+ type Offers = readonly StudioOptionValue[];
44
+ const AUTO_ON_OFF: Offers = ["auto", "on", "off"];
45
+ const ON_OFF: Offers = ["on", "off"];
46
+
47
+ /**
48
+ * Studio's printer types (resources/printers/<type>.json at the pinned commit:
49
+ * model_id/printer_type, printer_series, support_bed_leveling,
50
+ * support_auto_flow_calibration), keyed by this server's model names.
51
+ */
52
+ const STUDIO_PRINTERS: Record<string, { types: string[]; pSeries: boolean; bedLeveling: 0 | 1 | 2; autoFlow: boolean; dualNozzle: boolean }> = {
53
+ p1p: { types: ["C11"], pSeries: true, bedLeveling: 1, autoFlow: false, dualNozzle: false },
54
+ p1s: { types: ["C12"], pSeries: true, bedLeveling: 1, autoFlow: false, dualNozzle: false },
55
+ x1c: { types: ["3DPrinter-X1-Carbon"], pSeries: false, bedLeveling: 1, autoFlow: false, dualNozzle: false },
56
+ x1: { types: ["3DPrinter-X1"], pSeries: false, bedLeveling: 1, autoFlow: false, dualNozzle: false },
57
+ x1e: { types: ["C13"], pSeries: false, bedLeveling: 1, autoFlow: false, dualNozzle: false },
58
+ a1mini: { types: ["N1"], pSeries: false, bedLeveling: 1, autoFlow: false, dualNozzle: false },
59
+ a1: { types: ["N2S"], pSeries: false, bedLeveling: 1, autoFlow: false, dualNozzle: false },
60
+ p2s: { types: ["N7"], pSeries: false, bedLeveling: 2, autoFlow: true, dualNozzle: false },
61
+ x2d: { types: ["N6"], pSeries: false, bedLeveling: 2, autoFlow: true, dualNozzle: true },
62
+ h2s: { types: ["O1S"], pSeries: false, bedLeveling: 2, autoFlow: true, dualNozzle: false },
63
+ h2d: { types: ["O1D"], pSeries: false, bedLeveling: 2, autoFlow: true, dualNozzle: true },
64
+ h2dpro: { types: ["O1E"], pSeries: false, bedLeveling: 2, autoFlow: true, dualNozzle: true },
65
+ h2c: { types: ["O1C", "O1C2"], pSeries: false, bedLeveling: 2, autoFlow: true, dualNozzle: true },
66
+ };
67
+
68
+ export type StudioOptionSource = "caller" | "studio-config" | "studio-default";
69
+
70
+ /** Explicit caller choices; each one wins over Studio's resolution. */
71
+ export type CallerPrintOptions = {
72
+ bedLeveling?: boolean;
73
+ flowCalibration?: boolean;
74
+ vibrationCalibration?: boolean;
75
+ layerInspect?: boolean;
76
+ /** 0 off, 1 on, 2 auto, as Studio's getValueInt. */
77
+ autoBedLeveling?: number;
78
+ autoFlowCali?: number;
79
+ autoOffsetCali?: number;
80
+ extruderCaliManualMode?: number;
81
+ };
82
+
83
+ export type StudioPrintOptions = {
84
+ printerType?: string;
85
+ values: { bed_leveling: StudioOptionValue; flow_cali: StudioOptionValue; nozzle_offset_cali: StudioOptionValue };
86
+ sources: { bed_leveling: StudioOptionSource; flow_cali: StudioOptionSource; nozzle_offset_cali: StudioOptionSource };
87
+ task_bed_leveling: boolean;
88
+ task_flow_cali: boolean;
89
+ task_vibration_cali: boolean;
90
+ task_layer_inspect: boolean;
91
+ auto_bed_leveling: number;
92
+ auto_flow_cali: number;
93
+ auto_offset_cali: number;
94
+ extruder_cali_manual_mode: number;
95
+ };
96
+
97
+ /** Where Studio keeps BambuStudio.conf; BAMBU_STUDIO_CONFIG overrides it (empty: none). */
98
+ export function studioConfigPath(): string | undefined {
99
+ const override = process.env.BAMBU_STUDIO_CONFIG;
100
+ if (override !== undefined) return override.trim() || undefined;
101
+ if (process.platform === "darwin") return path.join(os.homedir(), "Library", "Application Support", "BambuStudio", "BambuStudio.conf");
102
+ if (process.platform === "win32") return process.env.APPDATA ? path.join(process.env.APPDATA, "BambuStudio", "BambuStudio.conf") : undefined;
103
+ return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "BambuStudio", "BambuStudio.conf");
104
+ }
105
+
106
+ /**
107
+ * The Send dialog options Studio saved for a printer type, and nothing else:
108
+ * BambuStudio.conf also holds access codes, so only that one section is kept,
109
+ * and a parse failure is reported without any of the file's text.
110
+ */
111
+ export function readStudioSavedOptions(printerTypes: string[], configPath: string | undefined): Record<string, string> | undefined {
112
+ if (!configPath) return undefined;
113
+ let text: string;
114
+ try {
115
+ const stat = fs.statSync(configPath);
116
+ if (!stat.isFile() || stat.size > 16 * 1024 * 1024) return undefined;
117
+ text = fs.readFileSync(configPath, "utf8");
118
+ } catch { return undefined; }
119
+ let config: unknown;
120
+ // Studio appends "# MD5 checksum <hex>" after the JSON.
121
+ try { config = JSON.parse(text.split("\n").filter(line => !line.trimStart().startsWith("#")).join("\n")); }
122
+ catch { return undefined; }
123
+ if (!config || typeof config !== "object") return undefined;
124
+ for (const type of printerTypes) {
125
+ const section = (config as Record<string, unknown>)[type];
126
+ if (!section || typeof section !== "object" || Array.isArray(section)) continue;
127
+ const saved: Record<string, string> = {};
128
+ for (const key of ["bed_leveling", "flow_cali", "nozzle_offset_cali", "timelapse"]) {
129
+ const value = (section as Record<string, unknown>)[key];
130
+ if (typeof value === "string") saved[key] = value;
131
+ }
132
+ return saved;
133
+ }
134
+ return undefined;
135
+ }
136
+
137
+ function fromInt(value: number, label: string): StudioOptionValue {
138
+ if (value === 0) return "off";
139
+ if (value === 1) return "on";
140
+ if (value === 2) return "auto";
141
+ throw new Error(`${label} must be 0 (off), 1 (on) or 2 (auto).`);
142
+ }
143
+
144
+ const toInt = (value: StudioOptionValue) => value === "off" ? 0 : value === "on" ? 1 : 2;
145
+
146
+ /** load_option_vals for one option: saved if offered, else auto if offered, else on. */
147
+ function loaded(saved: string | undefined, offers: Offers): { value: StudioOptionValue; source: StudioOptionSource } {
148
+ if (saved !== undefined && (offers as readonly string[]).includes(saved)) return { value: saved as StudioOptionValue, source: "studio-config" };
149
+ return { value: offers.includes("auto") ? "auto" : "on", source: "studio-default" };
150
+ }
151
+
152
+ /**
153
+ * What Studio's Send dialog would send for this model, with the caller's
154
+ * explicit choices taking precedence. usedNozzles is the number of nozzles the
155
+ * job uses, for dual-nozzle printers.
156
+ */
157
+ export function resolveStudioPrintOptions(model: string, caller: CallerPrintOptions = {}, options: { usedNozzles?: number; configPath?: string } = {}): StudioPrintOptions {
158
+ const printer = STUDIO_PRINTERS[model];
159
+ const saved = printer ? readStudioSavedOptions(printer.types, "configPath" in options ? options.configPath : studioConfigPath()) ?? {} : {};
160
+ const printerType = printer?.types[0];
161
+
162
+ const bedOffers = !printer || printer.bedLeveling !== 1 ? AUTO_ON_OFF : ON_OFF;
163
+ const flowOffers = !printer || printer.pSeries || printer.autoFlow ? AUTO_ON_OFF : ON_OFF;
164
+ const bed = loaded(saved.bed_leveling, bedOffers);
165
+ const flow = loaded(saved.flow_cali, flowOffers);
166
+ let offset = loaded(saved.nozzle_offset_cali, AUTO_ON_OFF);
167
+ if (printer?.dualNozzle && options.usedNozzles !== undefined) {
168
+ if (options.usedNozzles >= 2) offset = { value: "auto", source: "studio-default" };
169
+ else if (options.usedNozzles === 1) offset = { value: "off", source: "studio-default" };
170
+ }
171
+
172
+ const choose = (current: { value: StudioOptionValue; source: StudioOptionSource }, auto: number | undefined, flag: boolean | undefined, label: string) =>
173
+ auto !== undefined ? { value: fromInt(auto, label), source: "caller" as const }
174
+ : flag !== undefined ? { value: flag ? "on" as const : "off" as const, source: "caller" as const }
175
+ : current;
176
+ const bedLeveling = choose(bed, caller.autoBedLeveling, caller.bedLeveling, "auto_bed_leveling");
177
+ const flowCali = choose(flow, caller.autoFlowCali, caller.flowCalibration, "auto_flow_cali");
178
+ const nozzleOffset = choose(offset, caller.autoOffsetCali, undefined, "auto_offset_cali");
179
+ const manualMode = caller.extruderCaliManualMode ?? 0;
180
+ if (!Number.isInteger(manualMode) || manualMode < -1 || manualMode > 1) throw new Error("extruder_cali_manual_mode must be -1, 0 or 1.");
181
+
182
+ return {
183
+ ...(printerType ? { printerType } : {}),
184
+ values: { bed_leveling: bedLeveling.value, flow_cali: flowCali.value, nozzle_offset_cali: nozzleOffset.value },
185
+ sources: { bed_leveling: bedLeveling.source, flow_cali: flowCali.source, nozzle_offset_cali: nozzleOffset.source },
186
+ task_bed_leveling: bedLeveling.value === "on",
187
+ task_flow_cali: flowCali.value === "on",
188
+ task_vibration_cali: caller.vibrationCalibration ?? false,
189
+ task_layer_inspect: caller.layerInspect ?? true,
190
+ auto_bed_leveling: toInt(bedLeveling.value),
191
+ auto_flow_cali: toInt(flowCali.value),
192
+ auto_offset_cali: toInt(nozzleOffset.value),
193
+ extruder_cali_manual_mode: manualMode,
194
+ };
195
+ }
196
+
197
+ /** The number of nozzles a job's used filaments print from, when the inspection knows. */
198
+ export function usedNozzleCount(inspection: { usedFilamentPositions: number[]; filamentNozzleIndices?: Array<number | undefined> }): number | undefined {
199
+ const nozzles = inspection.usedFilamentPositions.map(position => inspection.filamentNozzleIndices?.[position]).filter((value): value is number => Number.isInteger(value));
200
+ return nozzles.length === inspection.usedFilamentPositions.length && nozzles.length ? new Set(nozzles).size : undefined;
201
+ }