bambu-printer-mcp 1.1.32 → 1.1.34

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.
@@ -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];