bambu-printer-mcp 1.1.31 → 1.1.33

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/src/index.ts CHANGED
@@ -45,7 +45,7 @@ import { analyze3MFAmsRequirements, analyze3MFPlateObjects, analyzeCollarCharm3M
45
45
  import type { ThreeMFAmsRequirements } from "./types.js";
46
46
  import { BambuImplementation, assertDirectPrintSupported } from "./printers/bambu.js";
47
47
  import { inspectPrintFile } from "./safety/print-file.js";
48
- import { validatePrinterState } from "./safety/printer-state.js";
48
+ import { externalStorageState, validatePrinterState } from "./safety/printer-state.js";
49
49
  import { isDualNozzleModel } from "./safety/limits.js";
50
50
  import { withPrinterOperation, withPrintSnapshot, uniquePrintName } from "./safety/artifact.js";
51
51
  import { detectProfilesRoot } from "./slicer/profile-flatten.js";
@@ -101,6 +101,20 @@ function withEmergencyMqttTimeout<T>(send: (signal: AbortSignal) => Promise<T>):
101
101
  return Promise.race([Promise.resolve().then(() => send(controller.signal)), timeout]).finally(() => clearTimeout(timer));
102
102
  }
103
103
 
104
+ /**
105
+ * Whether an X2D native print stores its job on the SD card or USB stick
106
+ * (true) or internal storage (eMMC). An explicit try_emmc_print wins, then
107
+ * BAMBU_NATIVE_TRY_EMMC_PRINT read as the helper reads it; otherwise a ready
108
+ * card is used, as OrcaSlicer does, so the job can be reprinted from the
109
+ * printer and read over FTPS.
110
+ */
111
+ function nativeExternalStorage(tryEmmcPrint: boolean | undefined, raw: unknown): boolean {
112
+ if (tryEmmcPrint !== undefined) return !tryEmmcPrint;
113
+ const configured = process.env.BAMBU_NATIVE_TRY_EMMC_PRINT?.trim();
114
+ if (configured) return !["1", "true", "TRUE", "yes"].includes(configured);
115
+ return externalStorageState(raw) === "normal";
116
+ }
117
+
104
118
  function requestedPrintNozzles(args: Record<string, any>): number[] | undefined {
105
119
  if (args.nozzle_diameters !== undefined && args.nozzle_diameter !== undefined) {
106
120
  throw new Error("Supply nozzle_diameters or nozzle_diameter, not both.");
@@ -1527,6 +1541,9 @@ class BambuPrinterMCPServer {
1527
1541
  if (!args?.three_mf_path) {
1528
1542
  throw new Error("Missing required parameter: three_mf_path");
1529
1543
  }
1544
+ if (args?.try_emmc_print !== undefined && typeof args.try_emmc_print !== "boolean") {
1545
+ throw new Error("try_emmc_print must be true or false.");
1546
+ }
1530
1547
 
1531
1548
  const printModel = await this.resolveBambuModel(args?.bambu_model as string | undefined);
1532
1549
  const printBedType = resolveBedType(args?.bed_type as string | undefined);
@@ -1674,6 +1691,10 @@ class BambuPrinterMCPServer {
1674
1691
 
1675
1692
  assertActive();
1676
1693
  if (native) {
1694
+ const externalStorage = nativeExternalStorage(args?.try_emmc_print, confirmedStatus.raw);
1695
+ if (externalStorage && externalStorageState(confirmedStatus.raw) !== "normal") {
1696
+ throw new Error("External storage was requested, but the printer reports no ready SD card or USB stick.");
1697
+ }
1677
1698
  // Release the telemetry client before the installed plug-in opens its session.
1678
1699
  await this.bambu.disconnectAll();
1679
1700
  assertActive();
@@ -1687,11 +1708,15 @@ class BambuPrinterMCPServer {
1687
1708
  vibrationCalibration: params.task_vibration_cali as boolean,
1688
1709
  layerInspect: params.task_layer_inspect as boolean,
1689
1710
  timelapse: params.task_record_timelapse as boolean,
1711
+ externalStorage,
1690
1712
  }, native.onUpdate, { signal: native.signal, assertActive, beforeDispatch: async () => {
1691
1713
  assertActive();
1692
1714
  const dispatchStatus = await this.bambu.getSafetyStatus(devIp, devId, password || bambuToken);
1693
1715
  validatePrinterState(dispatchStatus, requirements);
1694
1716
  this.bambu.assertBedClearance(dispatchStatus, bedClearance);
1717
+ if (externalStorage && externalStorageState(dispatchStatus.raw) !== "normal") {
1718
+ throw new Error("The printer's SD card or USB stick is no longer ready. Check it and start the print again.");
1719
+ }
1695
1720
  assertActive();
1696
1721
  } });
1697
1722
  assertActive();
@@ -2611,7 +2636,7 @@ class BambuPrinterMCPServer {
2611
2636
  use_ssl_for_ftp: { type: "boolean", description: "Whether FULU local print should use SSL for FTP (default: true)." },
2612
2637
  use_ssl_for_mqtt: { type: "boolean", description: "Whether FULU local print should use SSL for MQTT (default: true)." },
2613
2638
  external_change_assist: { type: "boolean", description: "Enable FULU PrintParams task_ext_change_assist for external filament change assistance." },
2614
- try_emmc_print: { type: "boolean", description: "Enable FULU PrintParams try_emmc_print for printers that support internal storage printing." },
2639
+ try_emmc_print: { type: "boolean", description: "Store the job on internal storage (eMMC) instead of the SD card or USB stick. FULU bridge: default false. X2D native printing: omit to use a ready card, else eMMC." },
2615
2640
  extra_options: { type: "string", description: "Raw JSON string or text for FULU PrintParams extra_options." },
2616
2641
  client_job_id: { type: "number", description: "Optional client job id sent to the bridge; defaults to the current timestamp." }
2617
2642
  },
@@ -2987,8 +3012,9 @@ class BambuPrinterMCPServer {
2987
3012
  connection_mode: {
2988
3013
  type: "string",
2989
3014
  enum: ["lan_mqtt_ftps", "bambu_network", "bambu_native", "bambu_connect"],
2990
- description: "Print path: bambu_connect hands a printable file to the signed-in Bambu Connect app for cloud-mode review; X2D otherwise defaults to bambu_native (Bambu Studio's installed local networking plug-in and emmc tunnel); lan_mqtt_ftps is the legacy direct MQTT/FTPS path; bambu_network uses the FULU bridge."
3015
+ description: "Print path: bambu_connect hands a printable file to the signed-in Bambu Connect app for cloud-mode review; X2D otherwise defaults to bambu_native (Bambu Studio's installed local networking plug-in, storing the job on a ready SD card or USB stick, otherwise internal eMMC); lan_mqtt_ftps is the legacy direct MQTT/FTPS path; bambu_network uses the FULU bridge."
2991
3016
  },
3017
+ try_emmc_print: { type: "boolean", description: "X2D native printing: true stores the job on internal storage (eMMC), false on the SD card or USB stick (refused unless the printer reports one ready). Omit to use the card when ready, else eMMC; BAMBU_NATIVE_TRY_EMMC_PRINT sets a server default." },
2992
3018
  connection_type: { type: "string", enum: ["cloud", "lan"], description: "BambuNetwork connection type when connection_mode is bambu_network; cloud uses restored internet printing, lan uses local bridge printing." },
2993
3019
  bambu_network_method: { type: "string", enum: BAMBU_NETWORK_PRINT_METHODS, description: "FULU print method when connection_mode is bambu_network; defaults to start_print for cloud and start_local_print for lan." },
2994
3020
  dev_id: { type: "string", description: "Bambu device id for FULU BambuNetwork printing; defaults to BAMBU_DEV_ID or BAMBU_SERIAL." },
@@ -424,3 +424,22 @@ export function manualHeatingRequirements(status: any, model: string, nozzleDiam
424
424
  if (tray < 254) { requirements.useAMS = true; requirements.amsMapping = [tray]; }
425
425
  return requirements;
426
426
  }
427
+
428
+ export type ExternalStorageState = "none" | "normal" | "abnormal" | "readonly";
429
+ const EXTERNAL_STORAGE_STATES: readonly ExternalStorageState[] = ["none", "normal", "abnormal", "readonly"];
430
+
431
+ /**
432
+ * The SD card or USB stick state, read as Bambu Studio does: the `sdcard`
433
+ * flag, then `home_flag` bits 8-9, then on firmware that reports cfg/fun/aux/stat
434
+ * the `aux` bits 12-13, each later source overriding the earlier one
435
+ * (DevStorage.cpp, DeviceManager.cpp parse_home_flag and parse_new_info).
436
+ */
437
+ export function externalStorageState(raw: unknown): ExternalStorageState {
438
+ if (!object(raw)) return "none";
439
+ let state: ExternalStorageState = raw.sdcard === true ? "normal" : "none";
440
+ if (Number.isInteger(raw.home_flag)) state = EXTERNAL_STORAGE_STATES[(raw.home_flag >> 8) & 3];
441
+ if (["cfg", "fun", "aux", "stat"].every(key => key in raw) && typeof raw.aux === "string" && /^[0-9a-f]+$/i.test(raw.aux)) {
442
+ state = EXTERNAL_STORAGE_STATES[Number((BigInt(`0x${raw.aux}`) >> 12n) & 3n)];
443
+ }
444
+ return state;
445
+ }
@@ -0,0 +1,379 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { profileJsonFiles } from './profile-files.js';
5
+
6
+ // OrcaSlicer 14da40e3a7b82aa8fc88593c2cf880e66560028f:
7
+ // PresetCacheFormat.{hpp,cpp}, Config.hpp, Config.cpp::save_to_json,
8
+ // Preset.hpp::VendorProfile and Semver.hpp. Only the named v2 format is read;
9
+ // the old ordinal-based cache must never be interpreted as this format.
10
+ const MAX_CACHE_BYTES = 64 * 1024 * 1024;
11
+ const MAX_ITEMS = 100_000;
12
+ const VECTOR = 0x4000;
13
+ // Nullable declarations in the same pinned PrintConfig.cpp; unknown nil values fail closed.
14
+ const NULLABLE_KEYS = new Set([
15
+ "bridge_acceleration",
16
+ "bridge_speed",
17
+ "default_acceleration",
18
+ "default_jerk",
19
+ "default_junction_deviation",
20
+ "deretract_speed_extruder_change",
21
+ "enable_overhang_speed",
22
+ "extruder_max_nozzle_count",
23
+ "filament_adaptive_volumetric_speed",
24
+ "filament_cooling_before_tower",
25
+ "filament_deretraction_speed",
26
+ "filament_flow_ratio",
27
+ "filament_flush_temp",
28
+ "filament_flush_temp_fast",
29
+ "filament_flush_volumetric_speed",
30
+ "filament_ironing_flow",
31
+ "filament_ironing_inset",
32
+ "filament_ironing_spacing",
33
+ "filament_ironing_speed",
34
+ "filament_long_retractions_when_cut",
35
+ "filament_pre_cooling_temperature",
36
+ "filament_pre_cooling_temperature_nc",
37
+ "filament_preheat_temperature_delta",
38
+ "filament_ramming_travel_time",
39
+ "filament_ramming_travel_time_nc",
40
+ "filament_ramming_volumetric_speed",
41
+ "filament_ramming_volumetric_speed_nc",
42
+ "filament_retract_after_wipe",
43
+ "filament_retract_before_wipe",
44
+ "filament_retract_length_nc",
45
+ "filament_retract_length_toolchange",
46
+ "filament_retract_lift_above",
47
+ "filament_retract_lift_below",
48
+ "filament_retract_lift_enforce",
49
+ "filament_retract_restart_extra",
50
+ "filament_retract_restart_extra_toolchange",
51
+ "filament_retract_when_changing_layer",
52
+ "filament_retraction_distances_when_cut",
53
+ "filament_retraction_length",
54
+ "filament_retraction_minimum_travel",
55
+ "filament_retraction_speed",
56
+ "filament_wipe",
57
+ "filament_wipe_distance",
58
+ "filament_z_hop",
59
+ "filament_z_hop_types",
60
+ "gap_infill_speed",
61
+ "hotend_cooling_rate",
62
+ "hotend_heating_rate",
63
+ "infill_jerk",
64
+ "initial_layer_acceleration",
65
+ "initial_layer_infill_speed",
66
+ "initial_layer_jerk",
67
+ "initial_layer_speed",
68
+ "initial_layer_travel_acceleration",
69
+ "initial_layer_travel_jerk",
70
+ "initial_layer_travel_speed",
71
+ "inner_wall_acceleration",
72
+ "inner_wall_jerk",
73
+ "inner_wall_speed",
74
+ "internal_bridge_speed",
75
+ "internal_solid_infill_acceleration",
76
+ "internal_solid_infill_speed",
77
+ "long_retractions_when_ec",
78
+ "nozzle_flush_dataset",
79
+ "nozzle_type",
80
+ "nozzle_volume",
81
+ "outer_wall_acceleration",
82
+ "outer_wall_jerk",
83
+ "outer_wall_speed",
84
+ "overhang_1_4_speed",
85
+ "overhang_2_4_speed",
86
+ "overhang_3_4_speed",
87
+ "overhang_4_4_speed",
88
+ "retraction_distances_when_ec",
89
+ "slowdown_for_curled_perimeters",
90
+ "small_perimeter_speed",
91
+ "small_perimeter_threshold",
92
+ "sparse_infill_acceleration",
93
+ "sparse_infill_speed",
94
+ "support_interface_speed",
95
+ "support_speed",
96
+ "top_solid_infill_flow_ratio",
97
+ "top_surface_acceleration",
98
+ "top_surface_jerk",
99
+ "top_surface_speed",
100
+ "travel_acceleration",
101
+ "travel_jerk",
102
+ "travel_speed",
103
+ "travel_speed_z",
104
+ ]);
105
+ const fail = (message: string): never => { throw new Error(`Orca profile cache: ${message}`); };
106
+
107
+ class Reader {
108
+ offset = 0;
109
+ constructor(readonly bytes: Buffer) {}
110
+ take(size: number): Buffer {
111
+ if (!Number.isSafeInteger(size) || size < 0 || size > this.bytes.length - this.offset) fail('truncated or invalid length');
112
+ const result = this.bytes.subarray(this.offset, this.offset + size);
113
+ this.offset += size;
114
+ return result;
115
+ }
116
+ u8(): number { return this.take(1).readUInt8(); }
117
+ u16(): number { return this.take(2).readUInt16LE(); }
118
+ u32(): number { return this.take(4).readUInt32LE(); }
119
+ i32(): number { return this.take(4).readInt32LE(); }
120
+ u64(): number {
121
+ const value = this.take(8).readBigUInt64LE();
122
+ if (value > BigInt(Number.MAX_SAFE_INTEGER)) fail('length exceeds safe range');
123
+ return Number(value);
124
+ }
125
+ count(): number {
126
+ const n = this.u64();
127
+ if (n > MAX_ITEMS || n > this.bytes.length - this.offset) fail('collection length out of bounds');
128
+ return n;
129
+ }
130
+ string(): string {
131
+ const raw = this.take(this.u64());
132
+ try { return new TextDecoder('utf-8', { fatal: true }).decode(raw); }
133
+ catch { return fail('invalid UTF-8'); }
134
+ }
135
+ strings(): string[] { return this.array(() => this.string()); }
136
+ array<T>(read: () => T): T[] { return Array.from({ length: this.count() }, read); }
137
+ double(): number { return this.take(8).readDoubleLE(); }
138
+ }
139
+
140
+ function crc32(bytes: Buffer): number {
141
+ let crc = 0xffffffff;
142
+ for (const byte of bytes) {
143
+ crc ^= byte;
144
+ for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
145
+ }
146
+ return (crc ^ 0xffffffff) >>> 0;
147
+ }
148
+
149
+ interface CachedProfile { subPath: string; data: Record<string, unknown>; }
150
+
151
+ /** Read raw source diffs, not merged profiles: inheritance/includes still use the shared resolver. */
152
+ export function decodeOrcaBblCache(bytes: Buffer): CachedProfile[] {
153
+ if (bytes.length < 20 || bytes.length > MAX_CACHE_BYTES) fail('file size out of bounds');
154
+ const header = new Reader(bytes);
155
+ if (header.u32() !== 0x4f52435a) fail('unknown magic');
156
+ if (header.u32() !== 2) fail('unsupported format version');
157
+ if (header.u64() !== bytes.length - 20) fail('body size does not match file');
158
+ const expectedCrc = header.u32();
159
+ const body = bytes.subarray(20);
160
+ if (crc32(body) !== expectedCrc) fail('CRC checksum mismatch');
161
+ const r = new Reader(body);
162
+ if (r.u32() !== 2) fail('unsupported body version');
163
+ if (r.string() !== 'BBL') fail('expected BBL vendor');
164
+ const version = r.string();
165
+ if (!/^\d+(?:\.\d+){2,3}(?:[-+][\w.-]+)?$/.test(version)) fail('invalid vendor version');
166
+ const keys = r.strings();
167
+ const types = r.array(() => r.u16());
168
+ const enums = r.strings();
169
+ if (keys.length !== types.length || keys.length > 65535 || enums.length > 65535 || enums[0] !== '') fail('invalid option dictionary');
170
+ if (new Set(keys).size !== keys.length || keys.some(key => !key || ['__proto__', 'constructor', 'prototype'].includes(key))) fail('invalid option name');
171
+
172
+ // VendorMap is std::map<string, VendorProfile>, serialized by cereal in declaration order.
173
+ const modelsMetadata: CachedProfile[] = [];
174
+ const vendors = r.count();
175
+ let ownVendor = false;
176
+ let vendorProfileVersion = "";
177
+ for (let i = 0; i < vendors; i++) {
178
+ const mapKey = r.string();
179
+ r.string(); // display name
180
+ const id = r.string();
181
+ const profileVersion = r.string();
182
+ if (mapKey === 'BBL' && id === 'BBL' && profileVersion) { ownVendor = true; vendorProfileVersion = profileVersion; }
183
+ r.string(); r.string(); // update/changelog URLs
184
+ const models = r.count();
185
+ for (let j = 0; j < models; j++) {
186
+ const modelId = r.string(); const modelName = r.string();
187
+ r.string(); r.u8(); r.string();
188
+ r.strings(); r.strings(); const unsupportedBeds = r.strings();
189
+ r.string(); r.string(); const imageBedType = r.string();
190
+ for (let k = 0; k < 6; k++) r.string();
191
+ if (mapKey === 'BBL' && modelName) {
192
+ modelsMetadata.push({ subPath: `machine/__models__/${encodeURIComponent(modelId)}.json`,
193
+ data: { name: modelName, type: 'machine_model',
194
+ ...(imageBedType ? { image_bed_type: imageBedType } : {}),
195
+ ...(unsupportedBeds.length ? { not_support_bed_type: unsupportedBeds.join(';') } : {}) } });
196
+ }
197
+ }
198
+ r.strings(); r.strings(); // default filament/material sets
199
+ }
200
+ if (!ownVendor) fail('cache does not carry its BBL vendor profile');
201
+
202
+ const number = (value: number, nullable = false): string => {
203
+ if (nullable && Number.isNaN(value)) return 'nil';
204
+ if (!Number.isFinite(value)) return fail('non-finite numeric option');
205
+ return String(value);
206
+ };
207
+ const point = (dimension: number, separator: string): string =>
208
+ Array.from({ length: dimension }, () => number(r.double())).join(separator);
209
+ const scalar = (type: number, vector: boolean): string => {
210
+ switch (type) {
211
+ case 1: return number(r.double(), vector);
212
+ case 2: { const n = r.i32(); return vector && n === 2147483647 ? 'nil' : String(n); }
213
+ case 3: return r.string();
214
+ case 4: { const n = r.double(); return Number.isNaN(n) && vector ? 'nil' : `${number(n)}%`; }
215
+ case 5: {
216
+ const n = r.double(); const percent = r.u8();
217
+ if (percent > 1) return fail('invalid percent flag');
218
+ return Number.isNaN(n) && vector ? 'nil' : number(n) + (percent ? '%' : '');
219
+ }
220
+ case 6: return point(2, vector ? 'x' : ',');
221
+ case 7: return point(3, ',');
222
+ case 8: {
223
+ const n = r.u8();
224
+ if (vector && n === 255) return 'nil';
225
+ if (n > 1) return fail('invalid boolean option');
226
+ return String(n);
227
+ }
228
+ default: return fail(`unsupported option type ${type}`);
229
+ }
230
+ };
231
+ const option = (type: number): string | string[] => {
232
+ if (type === 9 || type === VECTOR + 9) {
233
+ const count = r.u32();
234
+ if (count > MAX_ITEMS || (type === 9 && count !== 1)) fail('invalid enum count');
235
+ const values = Array.from({ length: count }, () => {
236
+ const index = r.u16();
237
+ if (index >= enums.length) return fail('enum index out of bounds');
238
+ if (index !== 0) return enums[index];
239
+ const value = r.i32();
240
+ if (type === VECTOR + 9 && value === 2147483647) return 'nil';
241
+ return fail('unnamed enum cannot be exported safely');
242
+ });
243
+ return type === 9 ? values[0] : values;
244
+ }
245
+ if (type === VECTOR + 10) return r.array(() => r.array(() => point(2, 'x')).join(','));
246
+ if (type === VECTOR + 11) return r.array(() => r.array(() => String(r.i32())).join(','));
247
+ if (type & VECTOR) return r.array(() => scalar(type - VECTOR, true));
248
+ return scalar(type, false);
249
+ };
250
+ const profiles: CachedProfile[] = [...modelsMetadata];
251
+ const names = new Set(profiles.map(p => String(p.data.name))); const paths = new Set(profiles.map(p => p.subPath.toLowerCase()));
252
+ for (const kind of ['process', 'filament', 'machine']) {
253
+ const count = r.u32();
254
+ if (count > MAX_ITEMS) fail('preset count out of bounds');
255
+ for (let i = 0; i < count; i++) {
256
+ const name = r.string(); const subPath = r.string();
257
+ if (!name || names.has(name)) fail('duplicate/empty preset name');
258
+ const normalized = subPath.replace(/\\/g, '/');
259
+ if (!normalized.startsWith(`${kind}/`) || !normalized.endsWith('.json') || normalized.split('/').some(p => !p || p === '.' || p === '..') || normalized.includes(':') || paths.has(normalized.toLowerCase())) fail('invalid or colliding preset path');
260
+ names.add(name); paths.add(normalized.toLowerCase());
261
+ const data: Record<string, unknown> = Object.create(null);
262
+ const configCount = r.u32(); const seen = new Set<number>();
263
+ if (configCount > keys.length) fail('option count out of bounds');
264
+ for (let j = 0; j < configCount; j++) {
265
+ const key = r.u16();
266
+ if (key >= keys.length || seen.has(key)) fail('invalid/duplicate option index');
267
+ seen.add(key);
268
+ const value = option(types[key]);
269
+ if (types[key] !== VECTOR + 3 && Array.isArray(value) && value.includes('nil') && !NULLABLE_KEYS.has(keys[key])) fail(`unexpected nil for ${keys[key]}`);
270
+ data[keys[key]] = value;
271
+ }
272
+ const inherits = r.string(); const includes = r.strings();
273
+ const description = r.string(); const instantiation = r.string();
274
+ const settingId = r.string(); const filamentId = r.string(); const renamed = r.strings();
275
+ Object.assign(data, { name, type: kind, from: 'system', version: vendorProfileVersion, instantiation, inherits });
276
+ if (includes.length) data.include = includes;
277
+ if (description) data.description = description;
278
+ if (settingId) data.setting_id = settingId;
279
+ if (filamentId) data.filament_id = filamentId;
280
+ if (renamed.length) data.renamed_from = renamed.map(value => JSON.stringify(value)).join(';');
281
+ profiles.push({ subPath: normalized, data });
282
+ }
283
+ }
284
+ if (r.u64() !== 0) fail('vendor cache records profile parse errors');
285
+ if (r.offset !== body.length) fail('unexpected trailing cache data');
286
+ return profiles;
287
+ }
288
+
289
+ /** Expand only the selected installation's cache in the owning server's private temp directory. */
290
+ export async function materializeOrcaProfilesRoot(profilesRoot: string, tempDir: string): Promise<string> {
291
+ if (profileJsonFiles(path.join(profilesRoot, 'BBL', 'machine')).length) return profilesRoot;
292
+ const cachePath = path.join(profilesRoot, 'BBL.opc');
293
+ let stat;
294
+ try { stat = await fs.stat(cachePath); }
295
+ catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return profilesRoot; throw error; }
296
+ if (stat.size > MAX_CACHE_BYTES) fail('file size out of bounds');
297
+ let cli: Buffer;
298
+ try { cli = await fs.readFile(path.join(profilesRoot, 'BBL', 'cli_config.json')); }
299
+ catch { return fail('selected installation is missing BBL/cli_config.json; update Orca or supply its exact matching profile tree'); }
300
+ const parsed: unknown = JSON.parse(cli.toString('utf8'));
301
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) fail('invalid BBL/cli_config.json');
302
+ const cache = await fs.readFile(cachePath);
303
+ const profiles = decodeOrcaBblCache(cache);
304
+ const digest = createHash('sha256').update(cache).update(cli).digest('hex');
305
+ const output = await fs.mkdtemp(path.join(tempDir, `orca-profiles-${digest.slice(0, 16)}-`));
306
+ try {
307
+ await fs.mkdir(path.join(output, 'BBL'), { recursive: true });
308
+ await fs.writeFile(path.join(output, 'BBL', 'cli_config.json'), cli, { flag: 'wx' });
309
+ await fs.writeFile(path.join(output, 'BBL.opc'), cache, { flag: 'wx' });
310
+ for (const profile of profiles) {
311
+ const file = path.join(output, 'BBL', profile.subPath);
312
+ await fs.mkdir(path.dirname(file), { recursive: true });
313
+ await fs.writeFile(file, JSON.stringify(profile.data), { flag: 'wx' });
314
+ }
315
+ return output;
316
+ } catch (error) {
317
+ await fs.rm(output, { recursive: true, force: true });
318
+ throw error;
319
+ }
320
+ }
321
+
322
+ interface CliProfiles { settingsArg?: string; filamentPaths: string[]; }
323
+
324
+ /** Orca v2 cache builds resolve User presets by loaded file identity, not just inherits. */
325
+ export async function stageOrcaCliProfiles(
326
+ profilesRoot: string, tempDir: string, bundle: CliProfiles
327
+ ): Promise<CliProfiles & { extraArgs: string[]; dataDir: string }> {
328
+ const settings = bundle.settingsArg?.split(';') ?? [];
329
+ if (settings.length !== 2) fail('complete machine and process settings are required');
330
+ const root = await fs.mkdtemp(path.join(tempDir, 'orca-cli-'));
331
+ try {
332
+ await fs.mkdir(path.join(root, 'system'));
333
+ await fs.copyFile(path.join(profilesRoot, 'BBL.opc'), path.join(root, 'system', 'BBL.opc'));
334
+ const profiles = decodeOrcaBblCache(await fs.readFile(path.join(profilesRoot, 'BBL.opc')));
335
+ const parents = new Set(profiles.map(p => `${p.data.type}:${p.data.name}`));
336
+ const version = profiles.find(p => p.data.type === 'machine')?.data.version;
337
+ if (typeof version !== 'string') fail('missing vendor profile version');
338
+ const output: string[] = [];
339
+ const colours: string[] = [];
340
+ const extras: string[] = [];
341
+ for (const [index, file] of [...settings, ...bundle.filamentPaths].entries()) {
342
+ const kind = index === 0 ? 'machine' : index === 1 ? 'process' : 'filament';
343
+ const data = JSON.parse(await fs.readFile(file, 'utf8')) as Record<string, unknown>;
344
+ if (kind === 'filament') {
345
+ const colour = Array.isArray(data.filament_colour) ? data.filament_colour[0] : undefined;
346
+ if (typeof colour !== 'string' || !/^#[0-9a-f]{6}(?:[0-9a-f]{2})?$/i.test(colour)) fail('missing positional filament colour');
347
+ colours.push(colour);
348
+ }
349
+ if (kind === 'process') {
350
+ if (typeof data.curr_bed_type === 'string') extras.push('--curr-bed-type', data.curr_bed_type);
351
+ for (const key of ['wipe_tower_x', 'wipe_tower_y']) {
352
+ if (data[key] === undefined) continue;
353
+ const values = Array.isArray(data[key]) ? data[key] as unknown[] : [data[key]];
354
+ if (!values.length || values.some(v => !Number.isFinite(Number(v)))) fail('invalid saved tower position');
355
+ extras.push(`--${key.replace(/_/g, '-')}`, values.join(','));
356
+ }
357
+ }
358
+ // The selected machine's system identity remains the validated cache leaf.
359
+ // A fully resolved custom process/filament may be a standalone User root.
360
+ if (!parents.has(`${kind}:${data.inherits}`)) {
361
+ if (kind === 'machine') fail('selected machine parent is absent from the exact cache');
362
+ delete data.inherits;
363
+ }
364
+ const digest = createHash('sha256').update(JSON.stringify(data)).digest('hex').slice(0, 16);
365
+ const name = `mcp_${kind}_${index}_${digest}`;
366
+ Object.assign(data, { name, from: 'User', version });
367
+ const directory = path.join(root, 'user', 'default', kind);
368
+ await fs.mkdir(directory, { recursive: true });
369
+ const staged = path.join(directory, `${name}.json`);
370
+ await fs.writeFile(staged, JSON.stringify(data), { flag: 'wx' });
371
+ output.push(staged);
372
+ }
373
+ return { settingsArg: output.slice(0, 2).join(';'), filamentPaths: output.slice(2),
374
+ extraArgs: ['--datadir', root, ...extras, '--filament-colour', colours.join(';')], dataDir: root };
375
+ } catch (error) {
376
+ await fs.rm(root, { recursive: true, force: true });
377
+ throw error;
378
+ }
379
+ }
@@ -836,7 +836,7 @@ export function detectProfilesRoot(slicerPath?: string, slicerType = 'bambustudi
836
836
  path.resolve(bin, '..', 'share', name, 'resources', 'profiles'),
837
837
  ]),
838
838
  ];
839
- const found = candidates.find(root => existsSync(path.join(root, 'BBL', 'machine')));
839
+ const found = candidates.find(root => (existsSync(path.join(root, 'BBL', 'machine')) || existsSync(path.join(root, 'BBL.opc'))));
840
840
  // Do not select another installation if this executable has no profile tree.
841
841
  // flattenForCli reports the missing tree and asks for BAMBU_PROFILES_ROOT.
842
842
  return found ?? candidates[0];
@@ -11,6 +11,7 @@ import * as crypto from 'crypto';
11
11
  import { execFile } from 'child_process';
12
12
  import { flattenForCli, detectProfilesRoot, resolveBblMachineProfile } from '../slicer/profile-flatten.js';
13
13
  import { profileJsonFiles } from '../slicer/profile-files.js';
14
+ import { materializeOrcaProfilesRoot, stageOrcaCliProfiles } from '../slicer/orca-profile-cache.js';
14
15
  import { sliceWithOrcaApi, validateRemoteSliceOptions } from '../slicer/remote-slicer.js';
15
16
 
16
17
  const readFileAsync = promisify(fs.readFile);
@@ -291,7 +292,7 @@ export class STLManipulator extends EventEmitter {
291
292
  return value;
292
293
  }
293
294
 
294
- private sanitizeProcessForOrca(processConfig: any, printerPreset?: string): any {
295
+ private sanitizeProcessForOrca(processConfig: any, printerPreset?: string, preserveExtrusionMode = false): any {
295
296
  const sanitized = { ...processConfig };
296
297
 
297
298
  sanitized.type = 'process';
@@ -312,7 +313,7 @@ export class STLManipulator extends EventEmitter {
312
313
  sanitized.prime_tower_brim_width = String(sanitized.prime_tower_brim_width);
313
314
  }
314
315
 
315
- return this.useAbsoluteExtrusionForOrca(sanitized);
316
+ return preserveExtrusionMode ? sanitized : this.useAbsoluteExtrusionForOrca(sanitized);
316
317
  }
317
318
 
318
319
  /** Orca slices with absolute E distances; per-layer `G92 E0` resets would break them. */
@@ -344,7 +345,8 @@ export class STLManipulator extends EventEmitter {
344
345
  slicerProfile?: string,
345
346
  printerPreset?: string,
346
347
  bambuOptions?: BambuSliceOptions,
347
- activeProfilesRoot?: string
348
+ activeProfilesRoot?: string,
349
+ preserveOrcaExtrusionMode = false
348
350
  ): Promise<BambuSettingsBundle> {
349
351
  const roots = activeProfilesRoot
350
352
  ? [path.join(activeProfilesRoot, 'BBL')]
@@ -417,7 +419,7 @@ export class STLManipulator extends EventEmitter {
417
419
  outputBase,
418
420
  slicerType === 'orcaslicer' ? 'process_orca' : 'process',
419
421
  slicerType === 'orcaslicer'
420
- ? this.sanitizeProcessForOrca(mergedProcess, printerPreset)
422
+ ? this.sanitizeProcessForOrca(mergedProcess, printerPreset, preserveOrcaExtrusionMode)
421
423
  : mergedProcess
422
424
  );
423
425
 
@@ -460,7 +462,7 @@ export class STLManipulator extends EventEmitter {
460
462
  ? this.writeTempJson(
461
463
  outputBase,
462
464
  'process_default_orca',
463
- this.sanitizeProcessForOrca(this.readJsonFile(defaultProcessPath), printerPreset)
465
+ this.sanitizeProcessForOrca(this.readJsonFile(defaultProcessPath), printerPreset, preserveOrcaExtrusionMode)
464
466
  )
465
467
  : defaultProcessPath;
466
468
  }
@@ -1608,6 +1610,7 @@ export class STLManipulator extends EventEmitter {
1608
1610
  let outputFilePath = path.join(this.tempDir, outputFileName);
1609
1611
 
1610
1612
  let args: string[] = [];
1613
+ const orcaTemporaryDirectories: string[] = [];
1611
1614
 
1612
1615
  try {
1613
1616
  if (progressCallback) progressCallback(0, `Starting slicing with ${slicerType}...`);
@@ -1660,14 +1663,19 @@ export class STLManipulator extends EventEmitter {
1660
1663
  const profileSlicerType = remote ? 'orcaslicer' : slicerType;
1661
1664
  const bambuOutputPath = path.join(this.tempDir, outputBase + (remote ? `_${operationId}` : '') + '_sliced.3mf');
1662
1665
  const outputDir = path.dirname(bambuOutputPath);
1663
- const profilesRoot = detectProfilesRoot(remote ? undefined : slicerPath, profileSlicerType);
1666
+ const selectedProfilesRoot = detectProfilesRoot(remote ? undefined : slicerPath, profileSlicerType);
1667
+ const profilesRoot = !remote && profileSlicerType === 'orcaslicer'
1668
+ ? await materializeOrcaProfilesRoot(selectedProfilesRoot, this.tempDir)
1669
+ : selectedProfilesRoot;
1670
+ if (profilesRoot !== selectedProfilesRoot) orcaTemporaryDirectories.push(profilesRoot);
1664
1671
  const rawBundle = await this.resolveBambuLikeSettingsBundle(
1665
1672
  outputBase,
1666
1673
  profileSlicerType,
1667
1674
  slicerProfile,
1668
1675
  printerPreset,
1669
1676
  bambuOptions,
1670
- profilesRoot
1677
+ profilesRoot,
1678
+ profileSlicerType === 'orcaslicer' && profilesRoot !== selectedProfilesRoot
1671
1679
  );
1672
1680
  // Resolve every BBL dependency before launching the slicer.
1673
1681
  const settingsBundle = await this.maybeFlattenBundle(
@@ -1689,7 +1697,7 @@ export class STLManipulator extends EventEmitter {
1689
1697
  // absolute-extrusion normalization after resolving those ancestors.
1690
1698
  // The machine owns the extrusion mode and layer G-code once process
1691
1699
  // files lose machine keys, so normalize it there as well.
1692
- if (profileSlicerType === 'orcaslicer' && settingsBundle.settingsArg) {
1700
+ if (profileSlicerType === 'orcaslicer' && settingsBundle.settingsArg && profilesRoot === selectedProfilesRoot) {
1693
1701
  const [machinePath, processPath] = settingsBundle.settingsArg.split(';');
1694
1702
  settingsBundle.settingsArg = [
1695
1703
  this.writeTempJson(outputBase, 'machine_orca_resolved',
@@ -1698,7 +1706,14 @@ export class STLManipulator extends EventEmitter {
1698
1706
  this.sanitizeProcessForOrca(this.readJsonFile(processPath), printerPreset)),
1699
1707
  ].join(';');
1700
1708
  }
1701
- const cliBundle = this.isolateMachineSettings(settingsBundle);
1709
+ let cliBundle = this.isolateMachineSettings(settingsBundle);
1710
+ let orcaExtraArgs: string[] = [];
1711
+ if (!remote && profileSlicerType === 'orcaslicer' && profilesRoot !== selectedProfilesRoot) {
1712
+ const staged = await stageOrcaCliProfiles(profilesRoot, this.tempDir, cliBundle);
1713
+ cliBundle = { ...cliBundle, settingsArg: staged.settingsArg, filamentPaths: staged.filamentPaths };
1714
+ orcaExtraArgs = staged.extraArgs;
1715
+ orcaTemporaryDirectories.push(staged.dataDir);
1716
+ }
1702
1717
  if (remote) {
1703
1718
  const [machinePath, processPath] = cliBundle.settingsArg?.split(';') ?? [];
1704
1719
  if (!machinePath || !processPath) throw new Error('Remote slicing requires complete prepared machine and process profiles.');
@@ -1710,6 +1725,7 @@ export class STLManipulator extends EventEmitter {
1710
1725
  break;
1711
1726
  }
1712
1727
  args = [
1728
+ ...orcaExtraArgs,
1713
1729
  '--slice', String(bambuOptions?.slicePlate ?? 0),
1714
1730
  '--outputdir', outputDir,
1715
1731
  '--export-3mf', path.basename(bambuOutputPath),
@@ -1825,6 +1841,11 @@ export class STLManipulator extends EventEmitter {
1825
1841
  throw error;
1826
1842
  } finally {
1827
1843
  this.activeOperations.delete(operationId);
1844
+ for (const directory of orcaTemporaryDirectories.reverse()) {
1845
+ await fs.promises.rm(directory, { recursive: true, force: true }).catch(error => {
1846
+ console.error('Could not remove temporary Orca profiles:', error);
1847
+ });
1848
+ }
1828
1849
  }
1829
1850
  }
1830
1851