bambu-printer-mcp 1.1.27 → 1.1.29

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 (38) hide show
  1. package/CONTRIBUTORS.md +7 -1
  2. package/README.md +29 -10
  3. package/dist/ams-mapping.d.ts +18 -2
  4. package/dist/ams-mapping.js +36 -5
  5. package/dist/bambu-native.d.ts +55 -0
  6. package/dist/bambu-native.js +212 -13
  7. package/dist/index.js +154 -71
  8. package/dist/printers/bambu.d.ts +29 -2
  9. package/dist/printers/bambu.js +59 -28
  10. package/dist/safety/hidden-hms.d.ts +32 -0
  11. package/dist/safety/hidden-hms.js +405 -0
  12. package/dist/safety/limits.d.ts +6 -0
  13. package/dist/safety/limits.js +9 -0
  14. package/dist/safety/print-file.d.ts +2 -0
  15. package/dist/safety/print-file.js +83 -7
  16. package/dist/safety/printer-state.d.ts +11 -0
  17. package/dist/safety/printer-state.js +115 -22
  18. package/dist/slicer/profile-flatten.js +25 -0
  19. package/dist/slicer/remote-slicer.d.ts +12 -0
  20. package/dist/slicer/remote-slicer.js +240 -0
  21. package/dist/stl/stl-manipulator.d.ts +2 -1
  22. package/dist/stl/stl-manipulator.js +71 -42
  23. package/native/bambu-native-print.cpp +100 -26
  24. package/package.json +7 -5
  25. package/scripts/build-bambu-native.sh +53 -0
  26. package/scripts/setup-bambu-native.mjs +372 -0
  27. package/src/ams-mapping.ts +38 -5
  28. package/src/bambu-native.ts +229 -14
  29. package/src/index.ts +156 -67
  30. package/src/printers/bambu.ts +65 -25
  31. package/src/safety/hidden-hms.ts +406 -0
  32. package/src/safety/limits.ts +9 -0
  33. package/src/safety/print-file.ts +71 -7
  34. package/src/safety/printer-state.ts +117 -22
  35. package/src/slicer/profile-flatten.ts +32 -0
  36. package/src/slicer/remote-slicer.ts +216 -0
  37. package/src/stl/stl-manipulator.ts +70 -43
  38. package/scripts/build-bambu-native.zsh +0 -33
@@ -7,8 +7,9 @@ import os from "node:os";
7
7
  import { Readable } from "node:stream";
8
8
  import { readSafe3mfArchive } from "../safety/archive.js";
9
9
  import { inspectPrintFile, type PrintFileInspection } from "../safety/print-file.js";
10
- import { normalizeModel, validateTemperature } from "../safety/limits.js";
11
- import { validatePrinterState, manualHeatingRequirements, type PrinterStateRequirements } from "../safety/printer-state.js";
10
+ import { isDualNozzleModel, normalizeModel, validateTemperature } from "../safety/limits.js";
11
+ import { bridgeAmsMapping2Entry, bridgeAmsMappingValue, externalTrayForNozzle, describeAmsTrayRange } from "../ams-mapping.js";
12
+ import { describeHmsKey, hiddenHmsKeys, hiddenHmsNotices, reportedHmsKey, validatePrinterState, manualHeatingRequirements, type PrinterStateRequirements } from "../safety/printer-state.js";
12
13
  import { withPrinterOperation, withPrintSnapshot, uniquePrintName, normalizedRemotePath, cancelPendingPrinterOperations } from "../safety/artifact.js";
13
14
  import { Client as FTPClient } from "basic-ftp";
14
15
  import { BambuPrinter } from "bambu-js";
@@ -85,7 +86,7 @@ const H2_MODEL_NAMES = new Set(["h2", "h2c", "h2d", "h2dpro", "h2d pro", "h2s",
85
86
  export function assertDirectPrintSupported(model: string | undefined, serial?: string): void {
86
87
  if (model?.trim().toLowerCase() === "x2d" || serial?.trim().toUpperCase().startsWith("20P")) {
87
88
  throw new Error(
88
- "X2D direct printing is not supported through legacy MQTT/FTPS. Use the macOS native eMMC route via print_3mf. " +
89
+ "X2D direct printing is not supported through legacy MQTT/FTPS. Use the native eMMC route via print_3mf (macOS, or Linux with the native helper installed). " +
89
90
  "X2D status and slicing remain available on all supported platforms."
90
91
  );
91
92
  }
@@ -461,6 +462,9 @@ class BambuClientStore {
461
462
  }
462
463
  }
463
464
 
465
+ /** What the human confirmed in confirmPrintPreflight, checked again before dispatch. */
466
+ export type PrintPreflight = { finishedJob?: string; serial: string; model: string; notices: string[] };
467
+
464
468
  export class BambuImplementation {
465
469
  private printerStore: BambuClientStore;
466
470
  private checkedJobs = new Map<string, { remotePath: string; requirements: PrinterStateRequirements }>();
@@ -481,25 +485,41 @@ export class BambuImplementation {
481
485
  return status.raw.gcode_state === "FINISH" ? JSON.stringify([status.raw.gcode_file, status.raw.subtask_name, status.raw.task_id, status.raw.subtask_id]) : undefined;
482
486
  }
483
487
 
484
- async confirmPrintPreflight(serial: string, status: any, inspection: Awaited<ReturnType<typeof inspectPrintFile>>): Promise<string | undefined> {
488
+ /**
489
+ * The human print confirmation. Its result goes to assertBedClearance at
490
+ * every later state check of the same print, which stops the print if the
491
+ * situation the human confirmed has changed.
492
+ */
493
+ async confirmPrintPreflight(serial: string, status: any, inspection: Awaited<ReturnType<typeof inspectPrintFile>>): Promise<PrintPreflight> {
485
494
  const finishedJob = this.finishedJobIdentity(status);
486
495
  const cleared = this.clearedErrors.get(serial);
496
+ const notices = hiddenHmsNotices(status?.raw, serial, inspection.model);
487
497
  await this.confirmHardwareAction(
488
498
  `Start a checked print on ${inspection.model.toUpperCase()} (${serial})? Nozzles: ${inspection.nozzleDiameters.join(", ")} mm. ` +
489
499
  `Materials: ${inspection.materials.join(", ")}. Peak targets: nozzle ${inspection.maxNozzleTemperature}°C, bed ${inspection.maxBedTemperature}°C, chamber ${inspection.maxChamberTemperature}°C. ` +
490
500
  `File SHA-256: ${inspection.sha256}. Confirm the physical spool labels and that the build plate is clear.` +
491
501
  (finishedJob !== undefined ? " The printer reports FINISH: remove the previous part and debris before confirming." : "") +
492
- (cleared ? ` Previously cleared hardware codes: ${cleared.join(", ")}. Confirm their physical causes have been resolved.` : ""),
502
+ (cleared ? ` Previously cleared hardware codes: ${cleared.map(describeHmsKey).join(", ")}. Confirm their physical causes have been resolved.` : "") +
503
+ (notices.length ? ` The printer also reports ${notices.join(", ")}, which Bambu lists for this model without text and never shows as a fault; ` +
504
+ `${notices.length === 1 ? "it does" : "they do"} not block printing.` : ""),
493
505
  finishedJob !== undefined || !!cleared
494
506
  );
495
- return finishedJob;
507
+ return { finishedJob, serial, model: inspection.model, notices };
496
508
  }
497
509
 
498
- assertBedClearance(status: any, confirmedFinishedJob: string | undefined): void {
510
+ /** Stop a print whose confirmed situation changed: a newly finished job, or a hidden HMS notice the preflight did not name. */
511
+ assertBedClearance(status: any, preflight: PrintPreflight): void {
499
512
  const current = this.finishedJobIdentity(status);
500
- if (current !== undefined && current !== confirmedFinishedJob) {
513
+ if (current !== undefined && current !== preflight.finishedJob) {
501
514
  throw new Error("Printer reports a newly finished job. Confirm that its part and debris have been removed before retrying the print.");
502
515
  }
516
+ // An X2D raises its hidden camera notice about 15 seconds after boot, so it
517
+ // can appear after the human confirmed. Never pass a notice nobody was told about.
518
+ const unnamed = hiddenHmsNotices(status?.raw, preflight.serial, preflight.model).filter(notice => !preflight.notices.includes(notice));
519
+ if (unnamed.length) {
520
+ throw new Error(`Printer reported ${unnamed.join(", ")} after the print confirmation. ` +
521
+ "Bambu hides it and it does not block printing, but it was not named when you confirmed; start the print again to review it.");
522
+ }
503
523
  }
504
524
 
505
525
  private async getPrinter(host: string, serial: string, token: string): Promise<BambuClient> {
@@ -728,15 +748,20 @@ export class BambuImplementation {
728
748
  // `plate_N.json.filament_ids` -- and expand to a full project-level
729
749
  // array here. `amsMapping` is the raw escape hatch (takes precedence
730
750
  // when both are supplied).
751
+ // Dual-nozzle printers address two external spools: 254 feeds the left/deputy
752
+ // nozzle and 255 the right/main nozzle (Bambu Studio VIRTUAL_TRAY_DEPUTY_ID /
753
+ // VIRTUAL_TRAY_MAIN_ID). Single-nozzle printers keep 254 as the only external tray.
754
+ const dualNozzle = isDualNozzleModel(model);
755
+ const externalTrays = dualNozzle ? [254, 255] : [254];
731
756
  const validateTrayValue = (v: unknown, label: string): void => {
732
757
  if (
733
758
  !Number.isInteger(v) ||
734
759
  (v as number) < -1 ||
735
760
  ((v as number) > 15 && (v as number) < 128) ||
736
- (v as number) > 254
761
+ (v as number) > (dualNozzle ? 255 : 254)
737
762
  ) {
738
763
  throw new Error(
739
- `${label} values must be integers in [-1, 15] (absolute tray) or 128-254 (HT/external); got ${v}`
764
+ `${label} values must be integers in ${describeAmsTrayRange({ dualNozzle })}; got ${v}`
740
765
  );
741
766
  }
742
767
  };
@@ -775,7 +800,7 @@ export class BambuImplementation {
775
800
  );
776
801
  baseMapping = Array<number>(projectLen).fill(-1);
777
802
  positions.forEach((pos, i) => {
778
- baseMapping[pos] = options.useAMS === false ? 254 : i;
803
+ baseMapping[pos] = options.useAMS === false ? externalTrayForNozzle(inspection.filamentNozzleIndices?.[pos], dualNozzle) : i;
779
804
  });
780
805
  }
781
806
 
@@ -786,12 +811,7 @@ export class BambuImplementation {
786
811
  amsMapping = Array.from({ length: projLen }, (_, i) =>
787
812
  i < baseMapping.length ? baseMapping[i] : -1
788
813
  );
789
- amsMapping2 = amsMapping.map((v) => {
790
- if (v < 0 || v === 255) return { ams_id: 255, slot_id: 255 };
791
- if (v === 254) return { ams_id: 254, slot_id: 254 };
792
- if (v >= 128) return { ams_id: 128, slot_id: v - 128 };
793
- return { ams_id: Math.floor(v / 4), slot_id: v % 4 };
794
- });
814
+ amsMapping2 = amsMapping.map((v) => bridgeAmsMapping2Entry(v, dualNozzle));
795
815
  } else {
796
816
  amsMapping = Array.from({ length: Math.max(5, baseMapping.length, projectMetadata.projectFilamentCount) }, (_, i) =>
797
817
  i < baseMapping.length ? baseMapping[i] : -1
@@ -803,8 +823,8 @@ export class BambuImplementation {
803
823
  if (baseMapping[position] === undefined || baseMapping[position] < 0) {
804
824
  throw new Error(`Missing physical filament mapping for project filament ${position}.`);
805
825
  }
806
- if (options.useAMS === false && baseMapping[position] !== 254) {
807
- throw new Error("External-spool printing requires external spool mapping (254).");
826
+ if (options.useAMS === false && !externalTrays.includes(baseMapping[position])) {
827
+ throw new Error(`External-spool printing requires external spool mapping (${externalTrays.join(" or ")}).`);
808
828
  }
809
829
  }
810
830
  const legacyContainer = options.filePath.toLowerCase().endsWith(".gcode.3mf") && !usesH2ProjectFile && !isA1;
@@ -879,7 +899,7 @@ export class BambuImplementation {
879
899
  project_id: submissionId,
880
900
  subtask_id: submissionId,
881
901
  task_id: submissionId,
882
- ams_mapping: amsMapping,
902
+ ams_mapping: amsMapping.map(value => bridgeAmsMappingValue(value, dualNozzle)),
883
903
  ams_mapping2: amsMapping2,
884
904
  },
885
905
  };
@@ -944,6 +964,21 @@ export class BambuImplementation {
944
964
  }
945
965
  }
946
966
 
967
+ /** Raw MQTT stop for routes that already ran the shared cancellation steps. */
968
+ async sendStopOverMqtt(host: string, serial: string, token: string): Promise<any> {
969
+ const printer = await this.getPrinter(host, serial, token);
970
+ await invokeWithoutAck(printer, new UpdateStateCommand({ state: "stop" }));
971
+ return { status: "success", message: "Cancel command sent over MQTT." };
972
+ }
973
+
974
+ /** Raw MQTT heater-off; switching off needs no material or model declaration. */
975
+ async sendHeaterOffOverMqtt(host: string, serial: string, token: string, heater: "bed" | "nozzle"): Promise<any> {
976
+ const printer = await this.getPrinter(host, serial, token);
977
+ const gcode = `${heater === "bed" ? "M140" : "M104"} S0`;
978
+ await invokeWithoutAck(printer, new GCodeLineCommand({ gcodes: [gcode] }));
979
+ return { status: "success", message: `Heater-off sent over MQTT for ${heater}.`, command: gcode };
980
+ }
981
+
947
982
  async pauseJob(host: string, serial: string, token: string, dispatch?: () => Promise<any>): Promise<any> {
948
983
  const printer = dispatch ? undefined : await this.getPrinter(host, serial, token);
949
984
  try {
@@ -982,10 +1017,12 @@ export class BambuImplementation {
982
1017
  return withPrinterOperation(host, serial, async assertActive => {
983
1018
  const codesFor = (status: any): string[] => {
984
1019
  if (!Array.isArray(status.raw?.hms) || status.raw?.print_error === undefined) throw new Error("Fresh error codes are required before clearing hardware errors.");
985
- return [`print_error:${status.raw.print_error}`, ...status.raw.hms.map((entry: any) => `hms:${entry.attr}:${entry.code}`)].sort();
1020
+ // Hidden notices never block and survive clean_print_error, so they are not errors to clear or re-acknowledge.
1021
+ const hidden = new Set(hiddenHmsKeys(status.raw, serial, String(status.model ?? "")));
1022
+ return [`print_error:${status.raw.print_error}`, ...status.raw.hms.map(reportedHmsKey).filter((key: string) => !hidden.has(key))].sort();
986
1023
  };
987
1024
  const codes = codesFor(await this.getSafetyStatus(host, serial, token));
988
- await this.confirmHardwareAction(`Clear reported hardware errors on ${serial}: ${codes.join(", ")}? Inspect the printer and resolve the physical cause first. Confirm only after removing obstructions and correcting the fault.`, true);
1025
+ await this.confirmHardwareAction(`Clear reported hardware errors on ${serial}: ${codes.map(describeHmsKey).join(", ")}? Inspect the printer and resolve the physical cause first. Confirm only after removing obstructions and correcting the fault.`, true);
989
1026
  const beforeDispatch = async () => {
990
1027
  assertActive();
991
1028
  const current = codesFor(await this.getSafetyStatus(host, serial, token));
@@ -1141,9 +1178,12 @@ export class BambuImplementation {
1141
1178
  }
1142
1179
  const printer = dispatch ? undefined : await this.getPrinter(host, serial, token);
1143
1180
  assertActive();
1144
- if (dispatch) await dispatch(heater, targetTemperature, assertActive, beforeDispatch);
1145
- else await invokeWithoutAck(printer!, new GCodeLineCommand({ gcodes: [gcode] }));
1146
- return { status: "success", message: `Temperature command sent for ${normalizedComponent}.`, command: gcode };
1181
+ const dispatched = dispatch ? await dispatch(heater, targetTemperature, assertActive, beforeDispatch) : undefined;
1182
+ if (!dispatch) await invokeWithoutAck(printer!, new GCodeLineCommand({ gcodes: [gcode] }));
1183
+ // Keep route details from a dual-route Linux heater-off.
1184
+ const routeDetails = dispatched && typeof dispatched === "object" && "routes" in dispatched
1185
+ ? { routes: dispatched.routes, native_error: dispatched.native_error, mqtt_error: dispatched.mqtt_error } : {};
1186
+ return { status: "success", message: `Temperature command sent for ${normalizedComponent}.`, command: gcode, ...routeDetails };
1147
1187
  };
1148
1188
  if (targetTemperature === 0) {
1149
1189
  cancelPendingPrinterOperations(host, serial);
@@ -0,0 +1,406 @@
1
+ // GENERATED by scripts/generate-hidden-hms.mjs from Bambu Studio v02.08.02.61
2
+ // (commit 926a7192574bcb9b3a732e1ec59a46d79cb45466, resources/hms/hms_en_<serial prefix>.json).
3
+ // Codes Bambu lists for a serial prefix with empty text in every entry. Do not edit by hand.
4
+ export const HIDDEN_HMS_SOURCE = {
5
+ studioTag: "v02.08.02.61",
6
+ studioCommit: "926a7192574bcb9b3a732e1ec59a46d79cb45466",
7
+ /** Per table: Bambu's device_hms version and the SHA-256 of the English file used. */
8
+ tables: {
9
+ "093": { version: 202508191204, sha256: "7801fd6c355854b62ecb62ee32727b4e5dcca908841dbcfd5e5de45b3dfd1c25" },
10
+ "094": { version: 202503231214, sha256: "047c15e2140738ec08258b74536e6cb996cddf0c586c5c39e2318e54125a9e76" },
11
+ "20P": { version: 202511162200, sha256: "e4f067d027b9bcbb9bbf6cb90f41f11130cf4d83dffd3dee101db7826dc808c3" },
12
+ "22E": { version: 202510142200, sha256: "87426021aea2bd4e8d721e46e1c86808fd7c6fc06f52baf6d952ead9e8d14d1e" },
13
+ "239": { version: 202506171403, sha256: "c1d23d5302756d57df5cb9bc795d95fc54ef1db7565dca1f04b69a024886426a" },
14
+ "31B": { version: 202511162200, sha256: "dcf63657bff5451f0715ea3c032efe482279bc8cbc28cb9b8ae51c7354826574" },
15
+ },
16
+ } as const;
17
+
18
+ export const HIDDEN_HMS: Readonly<Record<string, readonly string[]>> = {
19
+ "093": [
20
+ "0300180000000000",
21
+ "050001000001000A",
22
+ "0500010000020003",
23
+ "0500010000020008",
24
+ "0500010000020009",
25
+ "0500010000030009",
26
+ "0500030000010053",
27
+ "0500030000020010",
28
+ "0500030000020011",
29
+ "0500030000020012",
30
+ "0500030000020013",
31
+ "0500030000020014",
32
+ "0500030000020015",
33
+ "0500030000020016",
34
+ "0500030000020017",
35
+ "0500030000020018",
36
+ "0500030000030007",
37
+ "0500040000020020",
38
+ "0500050000010008",
39
+ "0500050000010009",
40
+ "050005000001000A",
41
+ "050005000001000B",
42
+ "050005000001000C",
43
+ "0500050000020030",
44
+ "0500060000020005",
45
+ "0500060000020007",
46
+ "0500060000020008",
47
+ "0500060000020011",
48
+ "0500060000020012",
49
+ "0500060000020013",
50
+ "0500060000020014",
51
+ "0500060000020021",
52
+ "0500060000020022",
53
+ "0500060000020023",
54
+ "0500060000020024",
55
+ "0500060000020041",
56
+ "0500060000020043",
57
+ "0500060000020044",
58
+ "0500060000020051",
59
+ "0500060000020053",
60
+ "0500060000020054",
61
+ "0500060000020061",
62
+ "0500060000020062",
63
+ "05000600000200A0",
64
+ "05000600000200A1",
65
+ "0C00010000010018",
66
+ "0C0001000002000E",
67
+ "0C00010000020015",
68
+ "0C00010000020016",
69
+ "0C00010000020019",
70
+ "0C0001000002001A",
71
+ "0C0001000002001B",
72
+ "0C00040000020006",
73
+ ],
74
+ "094": [
75
+ "0300180000000000",
76
+ "0300260000000000",
77
+ "050001000001000A",
78
+ "0500010000020003",
79
+ "0500010000020008",
80
+ "0500010000020009",
81
+ "0500010000030009",
82
+ "0500030000020010",
83
+ "0500030000020011",
84
+ "0500030000020012",
85
+ "0500030000020013",
86
+ "0500030000020014",
87
+ "0500030000020015",
88
+ "0500030000020016",
89
+ "0500030000020017",
90
+ "0500030000020018",
91
+ "0500030000030007",
92
+ "0500040000020020",
93
+ "0500050000010008",
94
+ "0500050000010009",
95
+ "050005000001000A",
96
+ "050005000001000B",
97
+ "050005000001000C",
98
+ "0500060000020001",
99
+ "0500060000020002",
100
+ "0500060000020003",
101
+ "0500060000020005",
102
+ "0500060000020006",
103
+ "0500060000020007",
104
+ "0500060000020008",
105
+ "0500060000020011",
106
+ "0500060000020012",
107
+ "0500060000020013",
108
+ "0500060000020014",
109
+ "0500060000020021",
110
+ "0500060000020022",
111
+ "0500060000020023",
112
+ "0500060000020024",
113
+ "0500060000020041",
114
+ "0500060000020042",
115
+ "0500060000020043",
116
+ "0500060000020044",
117
+ "0500060000020051",
118
+ "0500060000020052",
119
+ "0500060000020053",
120
+ "0500060000020054",
121
+ "0500060000020061",
122
+ "0500060000020062",
123
+ "05000600000200A0",
124
+ "05000600000200A1",
125
+ "0C00010000020015",
126
+ "0C00010000020016",
127
+ "0C00010000020019",
128
+ "0C0001000002001A",
129
+ "0C0001000002001B",
130
+ "0C00040000020006",
131
+ ],
132
+ "20P": [
133
+ "0300180000000000",
134
+ "0300260000000000",
135
+ "050001000001000A",
136
+ "0500010000020003",
137
+ "0500010000020008",
138
+ "0500010000020009",
139
+ "050001000002000B",
140
+ "050001000002000F",
141
+ "0500010000020010",
142
+ "0500010000020011",
143
+ "0500010000030009",
144
+ "0500020000030009",
145
+ "050002000003000A",
146
+ "050002000003000B",
147
+ "050002000003000C",
148
+ "0500030000010053",
149
+ "0500030000020010",
150
+ "0500030000020011",
151
+ "0500030000020012",
152
+ "0500030000020013",
153
+ "0500030000020014",
154
+ "0500030000020015",
155
+ "0500030000020016",
156
+ "0500030000020017",
157
+ "0500030000020018",
158
+ "0500030000020019",
159
+ "0500030000020050",
160
+ "0500030000020051",
161
+ "0500030000020052",
162
+ "0500030000020053",
163
+ "0500030000020054",
164
+ "0500030000030007",
165
+ "0500040000010056",
166
+ "0500040000010060",
167
+ "0500040000010061",
168
+ "0500040000020020",
169
+ "0500050000010008",
170
+ "0500050000010009",
171
+ "050005000001000A",
172
+ "050005000001000B",
173
+ "050005000001000C",
174
+ "0500050000020023",
175
+ "0500050000020030",
176
+ "0500060000020005",
177
+ "0500060000020006",
178
+ "0500060000020007",
179
+ "0500060000020008",
180
+ "0500060000020009",
181
+ "0500060000020011",
182
+ "0500060000020012",
183
+ "0500060000020013",
184
+ "0500060000020014",
185
+ "0500060000020015",
186
+ "0500060000020021",
187
+ "0500060000020022",
188
+ "0500060000020023",
189
+ "0500060000020024",
190
+ "0500060000020025",
191
+ "0500060000020035",
192
+ "0500060000020041",
193
+ "0500060000020042",
194
+ "0500060000020044",
195
+ "0500060000020045",
196
+ "0500060000020051",
197
+ "0500060000020052",
198
+ "0500060000020054",
199
+ "0500060000020061",
200
+ "0500060000020062",
201
+ "0500060000020070",
202
+ "0500060000020071",
203
+ "05000600000200A0",
204
+ "05000600000200A1",
205
+ "0500070000020001",
206
+ "0500070000020002",
207
+ "0500070000020003",
208
+ "0500070000020101",
209
+ "0500070000020201",
210
+ "0503000000030027",
211
+ "0503000000030028",
212
+ "0503000000030029",
213
+ "0C00010000010018",
214
+ "0C0001000002000E",
215
+ "0C00010000020015",
216
+ "0C00010000020016",
217
+ "0C00010000020019",
218
+ "0C0001000002001A",
219
+ "0C0001000002001B",
220
+ "0C0003000003001A",
221
+ "0C00040000020006",
222
+ ],
223
+ "22E": [
224
+ "0300180000000000",
225
+ "050001000001000A",
226
+ "0500010000020003",
227
+ "0500010000020008",
228
+ "0500010000020009",
229
+ "0500010000030009",
230
+ "0500030000010053",
231
+ "0500030000020010",
232
+ "0500030000020011",
233
+ "0500030000020012",
234
+ "0500030000020013",
235
+ "0500030000020014",
236
+ "0500030000020015",
237
+ "0500030000020016",
238
+ "0500030000020017",
239
+ "0500030000020018",
240
+ "0500030000030007",
241
+ "0500040000010056",
242
+ "0500040000020020",
243
+ "0500050000010008",
244
+ "0500050000010009",
245
+ "050005000001000A",
246
+ "050005000001000B",
247
+ "050005000001000C",
248
+ "0500050000020030",
249
+ "0500060000020005",
250
+ "0500060000020007",
251
+ "0500060000020008",
252
+ "0500060000020011",
253
+ "0500060000020012",
254
+ "0500060000020013",
255
+ "0500060000020014",
256
+ "0500060000020021",
257
+ "0500060000020022",
258
+ "0500060000020023",
259
+ "0500060000020024",
260
+ "0500060000020044",
261
+ "0500060000020054",
262
+ "0500060000020061",
263
+ "0500060000020062",
264
+ "05000600000200A0",
265
+ "05000600000200A1",
266
+ "0500070000020001",
267
+ "0500070000020002",
268
+ "0500070000020003",
269
+ "0500070000020101",
270
+ "0500070000020201",
271
+ "0503000000030027",
272
+ "0503000000030028",
273
+ "0503000000030029",
274
+ "0C00010000010018",
275
+ "0C0001000002000E",
276
+ "0C00010000020015",
277
+ "0C00010000020016",
278
+ "0C00010000020019",
279
+ "0C0001000002001A",
280
+ "0C0001000002001B",
281
+ "0C00040000020006",
282
+ ],
283
+ "239": [
284
+ "0300180000000000",
285
+ "0300260000000000",
286
+ "0500010000020003",
287
+ "0500010000020008",
288
+ "0500010000020009",
289
+ "0500010000030009",
290
+ "0500030000020010",
291
+ "0500030000020011",
292
+ "0500030000020012",
293
+ "0500030000020013",
294
+ "0500030000020014",
295
+ "0500030000020015",
296
+ "0500030000020016",
297
+ "0500030000020017",
298
+ "0500030000020018",
299
+ "0500030000030007",
300
+ "0500040000020020",
301
+ "0500050000010008",
302
+ "0500050000010009",
303
+ "050005000001000A",
304
+ "050005000001000B",
305
+ "050005000001000C",
306
+ "0500060000020005",
307
+ "0500060000020006",
308
+ "0500060000020007",
309
+ "0500060000020008",
310
+ "0500060000020011",
311
+ "0500060000020012",
312
+ "0500060000020013",
313
+ "0500060000020014",
314
+ "0500060000020021",
315
+ "0500060000020022",
316
+ "0500060000020023",
317
+ "0500060000020024",
318
+ "0500060000020041",
319
+ "0500060000020042",
320
+ "0500060000020043",
321
+ "0500060000020044",
322
+ "0500060000020051",
323
+ "0500060000020052",
324
+ "0500060000020053",
325
+ "0500060000020054",
326
+ "0500060000020061",
327
+ "0500060000020062",
328
+ "05000600000200A0",
329
+ "05000600000200A1",
330
+ "0C00010000020015",
331
+ "0C00010000020016",
332
+ "0C00010000020019",
333
+ "0C0001000002001A",
334
+ "0C0001000002001B",
335
+ "0C00040000020006",
336
+ ],
337
+ "31B": [
338
+ "0300180000000000",
339
+ "0300260000000000",
340
+ "050001000001000A",
341
+ "0500010000020003",
342
+ "0500010000020008",
343
+ "0500010000020009",
344
+ "0500010000030009",
345
+ "0500030000010053",
346
+ "0500030000020010",
347
+ "0500030000020011",
348
+ "0500030000020012",
349
+ "0500030000020013",
350
+ "0500030000020014",
351
+ "0500030000020015",
352
+ "0500030000020016",
353
+ "0500030000020017",
354
+ "0500030000020018",
355
+ "0500030000030007",
356
+ "0500040000010056",
357
+ "0500040000020020",
358
+ "0500050000010008",
359
+ "0500050000010009",
360
+ "050005000001000A",
361
+ "050005000001000B",
362
+ "050005000001000C",
363
+ "0500050000020030",
364
+ "0500060000020005",
365
+ "0500060000020006",
366
+ "0500060000020007",
367
+ "0500060000020008",
368
+ "0500060000020011",
369
+ "0500060000020012",
370
+ "0500060000020013",
371
+ "0500060000020014",
372
+ "0500060000020021",
373
+ "0500060000020022",
374
+ "0500060000020023",
375
+ "0500060000020024",
376
+ "0500060000020041",
377
+ "0500060000020042",
378
+ "0500060000020043",
379
+ "0500060000020044",
380
+ "0500060000020051",
381
+ "0500060000020052",
382
+ "0500060000020053",
383
+ "0500060000020054",
384
+ "0500060000020061",
385
+ "0500060000020062",
386
+ "05000600000200A0",
387
+ "05000600000200A1",
388
+ "0500070000020001",
389
+ "0500070000020002",
390
+ "0500070000020003",
391
+ "0500070000020101",
392
+ "0500070000020201",
393
+ "0503000000030027",
394
+ "0503000000030028",
395
+ "0503000000030029",
396
+ "0C00010000010018",
397
+ "0C0001000002000E",
398
+ "0C00010000020015",
399
+ "0C00010000020016",
400
+ "0C00010000020019",
401
+ "0C0001000002001A",
402
+ "0C0001000002001B",
403
+ "0C0003000003001A",
404
+ "0C00040000020006",
405
+ ],
406
+ };
@@ -31,6 +31,15 @@ export const MACHINE_LIMITS: Readonly<Record<string, {nozzle: number; bed: numbe
31
31
  x2d: {nozzle:300,bed:120,chamber:65,volume:[256,256,260]},
32
32
  };
33
33
 
34
+ /** Printers with two physical nozzles. Bambu Studio numbers the right/main nozzle 0
35
+ * (MAIN_EXTRUDER_ID) and the left/deputy nozzle 1 (DEPUTY_EXTRUDER_ID), and reports one
36
+ * virtual external-spool slot per nozzle: 255 (VIRTUAL_TRAY_MAIN_ID) and 254
37
+ * (VIRTUAL_TRAY_DEPUTY_ID). The single-nozzle H2S is deliberately absent. */
38
+ export const DUAL_NOZZLE_MODELS: ReadonlySet<string> = new Set(['h2d','h2dpro','h2c','x2d']);
39
+ export function isDualNozzleModel(model: unknown): boolean {
40
+ const normalized = normalizeModel(model);
41
+ return normalized !== undefined && DUAL_NOZZLE_MODELS.has(normalized);
42
+ }
34
43
  export function normalizeModel(value: unknown): string | undefined {
35
44
  if (typeof value !== 'string') return undefined;
36
45
  let model = value.trim().toLowerCase().replace(/^bambu\s*(?:lab\s*)?/, '').replace(/[\s_-]/g, '');