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.
package/src/index.ts CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  type BambuSliceOptions,
25
25
  type SlicerType,
26
26
  } from "./stl/stl-manipulator.js";
27
- import { BambuNetworkBridge, type BambuNetworkBridgeOptions } from "./bambu-network-bridge.js";
27
+ import { BambuNetworkBridge, describeBambuNetworkPrintError, type BambuNetworkBridgeOptions, type BridgeJobUpdate } from "./bambu-network-bridge.js";
28
28
  import {
29
29
  assertBambuNativeAvailable,
30
30
  bambuNativeControlsAvailable,
@@ -1642,12 +1642,17 @@ class BambuPrinterMCPServer {
1642
1642
  this.bambu.assertBedClearance(confirmedStatus, bedClearance);
1643
1643
  const amsMapping = stringifyBridgeJson(mapping?.map(value => bridgeAmsMappingValue(value, isDualNozzleModel(printModel))));
1644
1644
  const remoteFileName = uniquePrintName(threeMFPath);
1645
+ const checkedName = remoteFileName.replace(/(?:\.gcode)?\.3mf$/i, "");
1645
1646
  const params: Record<string, unknown> = {
1646
1647
  dev_id: devId,
1647
1648
  // Firmware can report the task name instead of the uploaded filename.
1648
1649
  // Bind either report to this inspected artifact for a later safe resume.
1649
- task_name: remoteFileName.replace(/(?:\.gcode)?\.3mf$/i, ""),
1650
- project_name: projectName,
1650
+ task_name: checkedName,
1651
+ // The plug-in names the uploaded file and the printer's job from
1652
+ // project_name, so it carries the same unique checked name, as the
1653
+ // native route already passes; a reused name would overwrite a stored
1654
+ // job and break the resume binding.
1655
+ project_name: checkedName,
1651
1656
  preset_name: presetName,
1652
1657
  filename: snapshot,
1653
1658
  config_filename: snapshot,
@@ -1723,22 +1728,31 @@ class BambuPrinterMCPServer {
1723
1728
  this.bambu.recordCheckedJob(devIp, devId, remoteFileName, requirements);
1724
1729
  return { ...nativeResult, autoSliced, projectName, plateIndex, useAMS, amsMapping: mapping };
1725
1730
  }
1726
- const bridgeResult = await this.bambuNetwork.callWithAgent(
1727
- bridgeMethod,
1728
- { client_job_id: clientJobId, params },
1729
- {
1730
- ...this.bridgeOptionsFromArgs(args, "print_3mf_bambu_network"),
1731
- beforeDispatch: async method => {
1732
- if (method !== bridgeMethod) return;
1733
- assertActive();
1734
- const dispatchStatus = await this.bambu.getSafetyStatus(devIp, devId, password || bambuToken);
1735
- validatePrinterState(dispatchStatus, requirements);
1736
- this.bambu.assertBedClearance(dispatchStatus, bedClearance);
1737
- },
1738
- assertDispatchAllowed: method => { if (method === bridgeMethod) assertActive(); },
1739
- onDispatched: method => { if (method === bridgeMethod) retainForDispatchedUpload(); },
1740
- }
1741
- );
1731
+ const bridgeOptions: BambuNetworkBridgeOptions = {
1732
+ ...this.bridgeOptionsFromArgs(args, "print_3mf_bambu_network"),
1733
+ beforeDispatch: async method => {
1734
+ if (method !== bridgeMethod) return;
1735
+ assertActive();
1736
+ const dispatchStatus = await this.bambu.getSafetyStatus(devIp, devId, password || bambuToken);
1737
+ validatePrinterState(dispatchStatus, requirements);
1738
+ this.bambu.assertBedClearance(dispatchStatus, bedClearance);
1739
+ },
1740
+ assertDispatchAllowed: method => { if (method === bridgeMethod) assertActive(); },
1741
+ onDispatched: method => { if (method === bridgeMethod) retainForDispatchedUpload(); },
1742
+ };
1743
+ let jobUpdates: BridgeJobUpdate[] = [];
1744
+ let bridgeResult: unknown;
1745
+ if (isLocalBridgePrint) {
1746
+ ({ result: bridgeResult, jobUpdates } = await this.bambuNetwork.startLocalPrint(
1747
+ { devId, devIp, username: String(params.username), password, useSsl: params.use_ssl_for_mqtt as boolean },
1748
+ bridgeMethod,
1749
+ { client_job_id: clientJobId, params },
1750
+ bridgeOptions,
1751
+ assertActive
1752
+ ));
1753
+ } else {
1754
+ bridgeResult = await this.bambuNetwork.callWithAgent(bridgeMethod, { client_job_id: clientJobId, params }, bridgeOptions);
1755
+ }
1742
1756
 
1743
1757
  if (typeof bridgeResult === "object" && bridgeResult !== null && (bridgeResult as { ok?: unknown }).ok === false) {
1744
1758
  throw new Error(`FULU BambuNetwork bridge method ${bridgeMethod} failed: ${String((bridgeResult as { error?: unknown }).error || "unknown bridge error")}`);
@@ -1750,7 +1764,10 @@ class BambuPrinterMCPServer {
1750
1764
  (bridgeResult as { value: number }).value !== 0
1751
1765
  ) {
1752
1766
  const value = (bridgeResult as { value: number }).value;
1753
- throw new Error(`FULU BambuNetwork bridge method ${bridgeMethod} returned non-zero result ${value}.`);
1767
+ const reported = jobUpdates.filter(update => update.code !== 0 || update.msg).slice(-3)
1768
+ .map(update => `status ${update.status}, code ${update.code}${update.msg ? `: ${update.msg}` : ""}`).join("; ");
1769
+ throw new Error(`FULU BambuNetwork bridge method ${bridgeMethod} returned non-zero result ${value}${describeBambuNetworkPrintError(value)}.` +
1770
+ (reported ? ` Plug-in reported: ${reported}.` : ""));
1754
1771
  }
1755
1772
 
1756
1773
  assertActive();
@@ -1766,6 +1783,7 @@ class BambuPrinterMCPServer {
1766
1783
  projectName,
1767
1784
  plateIndex,
1768
1785
  bridgePlateIndex: plateIndex + 1,
1786
+ printerJobName: checkedName,
1769
1787
  useAMS,
1770
1788
  amsMapping: mapping,
1771
1789
  params: redactPrintParams(params),
@@ -2500,11 +2518,11 @@ class BambuPrinterMCPServer {
2500
2518
  },
2501
2519
  use_printer_filaments: { type: "boolean", description: "When true, and no explicit slicer profile or load_filaments override is provided, use the printer's current or first loaded AMS filament as the slicer filament profile. Template 3MF process settings can still be used at the same time." },
2502
2520
  uptodate: { type: "boolean", description: "Refresh 3MF preset configs to match the latest BambuStudio version. Use when slicing downloaded or older 3MF files to prevent stale-config failures." },
2503
- repetitions: { type: "number", description: "Print N identical copies of the model. Each copy gets its own plate placement. Example: 3 prints three copies." },
2521
+ repetitions: { type: "number", description: "Print N identical copies of the model. Each copy gets its own plate placement. Example: 3 prints three copies. OrcaSlicer copies an STL with --clone-objects; do not combine with clone_objects." },
2504
2522
  orient: { type: "boolean", description: "Auto-orient the model for optimal printability (minimize supports, maximize bed adhesion). Recommended for raw STL imports that lack a pre-set orientation." },
2505
- arrange: { type: "boolean", description: "Auto-arrange all objects on the build plate with optimal spacing. Recommended when importing STLs or adding multiple objects. Set false to preserve existing plate layout." },
2523
+ arrange: { type: "boolean", description: "Auto-arrange all objects on the build plate with optimal spacing; STL input is arranged by default. Set false only to keep a 3MF's existing layout: an STL that is not already on the plate then fails to slice." },
2506
2524
  ensure_on_bed: { type: "boolean", description: "Detect models floating above the bed and lower them onto the build surface. Safety net for imported models with incorrect Z origins." },
2507
- clone_objects: { type: "string", description: "Duplicate specific objects on the plate. Comma-separated clone counts per object index, e.g. '1,3,1,10' clones object 0 once, object 1 three times, etc." },
2525
+ clone_objects: { type: "string", description: "Clone the loaded model: total copies as a single integer, e.g. '3'. The Bambu-style CLIs count copies per input file and this tool loads one file, so a comma-separated list is rejected by the slicer." },
2508
2526
  skip_objects: { type: "string", description: "Skip specific objects during slicing by index. Comma-separated, e.g. '3,5,10'. Useful for multi-object 3MFs where you only want to print some parts." },
2509
2527
  load_filaments: { type: "string", description: "Override filament profiles. Semicolon-separated paths to filament JSON configs, e.g. 'pla_basic.json;petg_cf.json'." },
2510
2528
  filament_profile: { type: "string", description: "Compatibility alias for load_filaments. Semicolon-separated Orca/Bambu filament profile JSON paths." },
@@ -2606,7 +2624,7 @@ class BambuPrinterMCPServer {
2606
2624
  nozzle_type: { type: "string", enum: [...VALID_NOZZLE_TYPES], description: "Installed nozzle material, used when the 3MF must be auto-sliced (default: BAMBU_NOZZLE_TYPE, else the preset's stock nozzle)." },
2607
2625
  nozzle_volume_type: { type: "string", enum: [...VALID_NOZZLE_VOLUME_TYPES], description: "Installed nozzle flow type, used when the 3MF must be auto-sliced (default: BAMBU_NOZZLE_VOLUME_TYPE, else standard)." },
2608
2626
  plate_index: { type: "number", description: "Zero-based plate index to print from the sliced 3MF; converted to FULU's one-based PrintParams plate_index." },
2609
- project_name: { type: "string", description: "Optional project name sent in FULU PrintParams; defaults to the 3MF filename without extension." },
2627
+ project_name: { type: "string", description: "Optional project name returned in the result; defaults to the 3MF filename without extension. The printer job and stored file use the server's unique checked name so resume_print can bind to the inspected file." },
2610
2628
  preset_name: { type: "string", description: "Optional preset name sent in FULU PrintParams; defaults to project plus one-based plate index." },
2611
2629
  task_name: { type: "string", description: "Optional project label when project_name is omitted. The submitted task name uses a unique inspected-job identity for safe resume." },
2612
2630
  config_filename: { type: "string", description: "Optional config 3MF path for cloud print; defaults to the same 3MF path." },
@@ -2650,7 +2668,7 @@ class BambuPrinterMCPServer {
2650
2668
  type: "object",
2651
2669
  properties: {
2652
2670
  save_path: { type: "string", description: "Optional absolute path to write the JPEG to disk. If omitted, only the base64 payload is returned." },
2653
- timeout_ms: { type: "number", description: "Max ms to wait for a full frame (default 8000). Camera may take a few seconds on cold start." },
2671
+ timeout_ms: { type: "number", description: "Max ms to wait for a live frame (default 8000). A1/P1 cameras first replay a buffered frame, which is skipped, so a live frame takes about 3 s, longer on cold start." },
2654
2672
  bambu_model: { type: "string", description: "Printer model. Used to route to the correct protocol or fail fast on unsupported models. Defaults to BAMBU_MODEL." },
2655
2673
  experimental: { type: "boolean", description: "Deprecated and ignored. Earlier this flag let callers probe H2 series via the A1/P1 TCP-on-6000 path; live testing on an H2S confirmed H2 uses RTSP instead, so the flag has no effect now." },
2656
2674
  ffmpeg_path: { type: "string", description: "Override path to the ffmpeg binary used by the RTSP path. Defaults to FFMPEG_PATH or ffmpeg via $PATH. Per-call overrides require MCP_ALLOW_EXECUTABLE_ARG=1. Required only for the RTSP transport (X1, P2S, H2 series, X2D)." },
@@ -9,7 +9,7 @@ import { readSafe3mfArchive } from "../safety/archive.js";
9
9
  import { inspectPrintFile, type PrintFileInspection } from "../safety/print-file.js";
10
10
  import { isDualNozzleModel, normalizeModel, validateTemperature } from "../safety/limits.js";
11
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
+ import { describeHmsKey, hiddenHmsKeys, hiddenHmsNotices, reportedHmsKey, unchippedNozzles, validatePrinterState, manualHeatingRequirements, type PrinterStateRequirements } from "../safety/printer-state.js";
13
13
  import { withPrinterOperation, withPrintSnapshot, uniquePrintName, normalizedRemotePath, cancelPendingPrinterOperations } from "../safety/artifact.js";
14
14
  import { Client as FTPClient } from "basic-ftp";
15
15
  import { BambuPrinter } from "bambu-js";
@@ -348,6 +348,7 @@ class BambuClientStore {
348
348
  timeoutMs = 4000
349
349
  ): Promise<Record<string, any> | null> {
350
350
  const key = getPrinterKey(host, serial, token);
351
+ const deadline = Date.now() + timeoutMs + 500;
351
352
  const existing = this.reportSnapshots.get(key);
352
353
 
353
354
  if (!existing || Object.keys(existing).length === 0) {
@@ -370,8 +371,11 @@ class BambuClientStore {
370
371
  // (gcode_state, ams, hms, temperatures, fans, etc.) arrives afterward
371
372
  // and gets merged into reportSnapshots via incremental update.
372
373
  // This settle runs regardless of whether data arrived before or during
373
- // the promise race above, so the merge has time to complete.
374
+ // the promise race above, so the merge has time to complete. A busy host
375
+ // can take longer, which returned the sparse report as status UNKNOWN,
376
+ // so keep waiting for the full status within the same bound.
374
377
  await sleep(500);
378
+ while (typeof this.reportSnapshots.get(key)?.gcode_state !== "string" && Date.now() < deadline) await sleep(50);
375
379
  return this.reportSnapshots.get(key) || null;
376
380
  }
377
381
 
@@ -471,6 +475,36 @@ class BambuClientStore {
471
475
  /** What the human confirmed in confirmPrintPreflight, checked again before dispatch. */
472
476
  export type PrintPreflight = { finishedJob?: string; serial: string; model: string; notices: string[] };
473
477
 
478
+ /**
479
+ * Split the A1/P1 camera stream on port 6000 into JPEG frames. Each frame is
480
+ * a 16-byte header (payload size LE, itrack, flags, reserved) and the JPEG.
481
+ */
482
+ export function cameraFrameParser(onFrame: (jpeg: Buffer) => void, onError: (error: Error) => void): (chunk: Buffer) => void {
483
+ let buffered = Buffer.alloc(0);
484
+ let failed = false;
485
+ return chunk => {
486
+ if (failed) return;
487
+ buffered = Buffer.concat([buffered, chunk]);
488
+ while (buffered.length >= 16) {
489
+ const payloadSize = buffered.readUInt32LE(0);
490
+ if (payloadSize <= 0 || payloadSize > 5_000_000) {
491
+ failed = true;
492
+ onError(new Error(`camera_snapshot: unreasonable payload size ${payloadSize} from header; auth likely failed.`));
493
+ return;
494
+ }
495
+ if (buffered.length < 16 + payloadSize) return;
496
+ const jpeg = buffered.subarray(16, 16 + payloadSize);
497
+ buffered = buffered.subarray(16 + payloadSize);
498
+ if (jpeg[0] !== 0xff || jpeg[1] !== 0xd8) {
499
+ failed = true;
500
+ onError(new Error(`camera_snapshot: payload does not start with JPEG SOI (FF D8); got ${jpeg[0].toString(16)} ${jpeg[1].toString(16)}.`));
501
+ return;
502
+ }
503
+ onFrame(Buffer.from(jpeg));
504
+ }
505
+ };
506
+ }
507
+
474
508
  export class BambuImplementation {
475
509
  private printerStore: BambuClientStore;
476
510
  private checkedJobs = new Map<string, { remotePath: string; requirements: PrinterStateRequirements }>();
@@ -482,8 +516,14 @@ export class BambuImplementation {
482
516
 
483
517
  async confirmHardwareAction(message: string, physicalCheck = false): Promise<void> {
484
518
  if (!physicalCheck && process.env.BAMBU_REQUIRE_CONFIRMATION === "0") return;
485
- if (!this.confirm || !(await this.confirm(message))) {
486
- throw new Error("Hardware safety confirmation was declined or unavailable. Use an MCP client with elicitation support. No command was sent.");
519
+ if (!this.confirm) {
520
+ throw new Error("Hardware safety confirmation is unavailable. Use an MCP client with elicitation support. No command was sent.");
521
+ }
522
+ if (!(await this.confirm(message))) {
523
+ throw new Error("Hardware safety confirmation was declined or cancelled in the MCP client. No command was sent. " + (physicalCheck
524
+ ? "This confirmation covers a physical check at the printer and always needs a person to accept it."
525
+ : "Some clients decline prompts automatically, for example in a non-interactive permission mode. " +
526
+ "A deliberately headless setup that approves jobs another way can set BAMBU_REQUIRE_CONFIRMATION=0 to skip ordinary print and heating prompts."));
487
527
  }
488
528
  }
489
529
 
@@ -500,6 +540,7 @@ export class BambuImplementation {
500
540
  const finishedJob = this.finishedJobIdentity(status);
501
541
  const cleared = this.clearedErrors.get(serial);
502
542
  const notices = hiddenHmsNotices(status?.raw, serial, inspection.model);
543
+ const unchipped = unchippedNozzles(status?.raw);
503
544
  await this.confirmHardwareAction(
504
545
  `Start a checked print on ${inspection.model.toUpperCase()} (${serial})? Nozzles: ${inspection.nozzleDiameters.join(", ")} mm. ` +
505
546
  `Materials: ${inspection.materials.join(", ")}. Peak targets: nozzle ${inspection.maxNozzleTemperature}°C, bed ${inspection.maxBedTemperature}°C, chamber ${inspection.maxChamberTemperature}°C. ` +
@@ -507,7 +548,9 @@ export class BambuImplementation {
507
548
  (finishedJob !== undefined ? " The printer reports FINISH: remove the previous part and debris before confirming." : "") +
508
549
  (cleared ? ` Previously cleared hardware codes: ${cleared.map(describeHmsKey).join(", ")}. Confirm their physical causes have been resolved.` : "") +
509
550
  (notices.length ? ` The printer also reports ${notices.join(", ")}, which Bambu lists for this model without text and never shows as a fault; ` +
510
- `${notices.length === 1 ? "it does" : "they do"} not block printing.` : ""),
551
+ `${notices.length === 1 ? "it does" : "they do"} not block printing.` : "") +
552
+ (unchipped.length ? ` Printer ${unchipped.length === 1 ? `nozzle ${unchipped[0]} reports` : `nozzles ${unchipped.join(" and ")} report`} no identity chip, ` +
553
+ `so ${unchipped.length === 1 ? "its" : "their"} diameter and type are the printer's configured settings; confirm they match the installed ${unchipped.length === 1 ? "hotend" : "hotends"}.` : ""),
511
554
  finishedJob !== undefined || !!cleared
512
555
  );
513
556
  return { finishedJob, serial, model: inspection.model, notices };
@@ -1828,13 +1871,19 @@ export class BambuImplementation {
1828
1871
  }
1829
1872
 
1830
1873
  /**
1831
- * Open the TLS-on-6000 socket, send the 80-byte auth packet, and read
1832
- * a single complete JPEG frame. Returns the JPEG bytes.
1874
+ * Open the TLS-on-6000 socket, send the 80-byte auth packet, and return
1875
+ * a live JPEG frame. The printer first replays the last frame it buffered,
1876
+ * which after an idle period can show the chamber as it was long ago (a
1877
+ * finished part still on the bed), so that frame and any byte-identical
1878
+ * repeat of it are discarded and the next frame returned; live frames always
1879
+ * differ by sensor noise. If no live frame arrives in time, this fails rather
1880
+ * than return a possibly stale image.
1833
1881
  */
1834
1882
  private async fetchTcpCameraFrame(
1835
1883
  host: string,
1836
1884
  token: string,
1837
- timeoutMs: number
1885
+ timeoutMs: number,
1886
+ port = 6000
1838
1887
  ): Promise<Buffer> {
1839
1888
  const tls = await import("node:tls");
1840
1889
 
@@ -1846,11 +1895,9 @@ export class BambuImplementation {
1846
1895
  auth.write(token, 48, Math.min(32, Buffer.byteLength(token, "ascii")), "ascii");
1847
1896
 
1848
1897
  return new Promise((resolve, reject) => {
1849
- const chunks: Buffer[] = [];
1850
- let totalLen = 0;
1851
- const FRAME_HEADER_BYTES = 16;
1852
- let payloadSize: number | null = null;
1853
1898
  let settled = false;
1899
+ let frames = 0;
1900
+ let buffered: Buffer | undefined;
1854
1901
 
1855
1902
  const finish = (err: Error | null, jpeg?: Buffer) => {
1856
1903
  if (settled) return;
@@ -1861,16 +1908,25 @@ export class BambuImplementation {
1861
1908
  else if (jpeg) resolve(jpeg);
1862
1909
  else reject(new Error("camera_snapshot: ended without jpeg payload"));
1863
1910
  };
1911
+ const unfinished = (reason: string) => new Error(frames
1912
+ ? `camera_snapshot: ${reason} after only the printer's buffered frame, which may be stale; no live frame arrived. Retry, or raise timeout_ms.`
1913
+ : `camera_snapshot: ${reason} before a full JPEG frame arrived`);
1864
1914
 
1865
- const timer = setTimeout(
1866
- () => finish(new Error(`camera_snapshot: timed out after ${timeoutMs}ms`)),
1867
- timeoutMs
1915
+ const timer = setTimeout(() => finish(unfinished(`timed out after ${timeoutMs}ms`)), timeoutMs);
1916
+
1917
+ const parse = cameraFrameParser(
1918
+ jpeg => {
1919
+ frames++;
1920
+ if (!buffered) buffered = jpeg;
1921
+ else if (!jpeg.equals(buffered)) finish(null, jpeg);
1922
+ },
1923
+ error => finish(error)
1868
1924
  );
1869
1925
 
1870
1926
  const socket = tls.connect(
1871
1927
  {
1872
1928
  host,
1873
- port: 6000,
1929
+ port,
1874
1930
  rejectUnauthorized: false,
1875
1931
  // The printer uses TLS for confidentiality but presents a self-signed cert.
1876
1932
  // Same trust posture as the FTPS path (basic-ftp with rejectUnauthorized: false).
@@ -1880,49 +1936,9 @@ export class BambuImplementation {
1880
1936
  }
1881
1937
  );
1882
1938
 
1883
- socket.on("data", (data: Buffer) => {
1884
- chunks.push(data);
1885
- totalLen += data.length;
1886
-
1887
- if (payloadSize === null && totalLen >= FRAME_HEADER_BYTES) {
1888
- const merged = Buffer.concat(chunks, totalLen);
1889
- payloadSize = merged.readUInt32LE(0);
1890
- if (payloadSize <= 0 || payloadSize > 5_000_000) {
1891
- finish(
1892
- new Error(
1893
- `camera_snapshot: unreasonable payload size ${payloadSize} from header; auth likely failed.`
1894
- )
1895
- );
1896
- return;
1897
- }
1898
- // Reset chunk list to remaining bytes after the header.
1899
- const remainder = merged.subarray(FRAME_HEADER_BYTES);
1900
- chunks.length = 0;
1901
- chunks.push(remainder);
1902
- totalLen = remainder.length;
1903
- }
1904
-
1905
- if (payloadSize !== null && totalLen >= payloadSize) {
1906
- const merged = Buffer.concat(chunks, totalLen);
1907
- const jpeg = merged.subarray(0, payloadSize);
1908
- if (jpeg[0] !== 0xff || jpeg[1] !== 0xd8) {
1909
- finish(
1910
- new Error(
1911
- `camera_snapshot: payload does not start with JPEG SOI (FF D8); got ${jpeg[0].toString(16)} ${jpeg[1].toString(16)}.`
1912
- )
1913
- );
1914
- return;
1915
- }
1916
- finish(null, jpeg);
1917
- }
1918
- });
1919
-
1939
+ socket.on("data", parse);
1920
1940
  socket.on("error", (err) => finish(err));
1921
- socket.on("end", () =>
1922
- finish(
1923
- new Error("camera_snapshot: connection ended before a full JPEG frame arrived")
1924
- )
1925
- );
1941
+ socket.on("end", () => finish(unfinished("connection ended")));
1926
1942
  });
1927
1943
  }
1928
1944
 
@@ -138,6 +138,43 @@ function nozzleTypeAndFlow(value: unknown): {type?: string; flow?: string} {
138
138
  const type = token(value);
139
139
  return {type: ["stainlesssteel","hardenedsteel","tungstencarbide","brass"].includes(type) ? type : undefined};
140
140
  }
141
+ /**
142
+ * A nozzle without an identity chip reports serial "N/A" and keeps the
143
+ * unreliable bit set permanently (stock H2D HS01 nozzles), so its diameter
144
+ * and type are configured settings, as on single-nozzle printers.
145
+ */
146
+ function hasNoIdentityChip(entry: any): boolean {
147
+ return typeof entry?.sn === "string" && entry.sn.trim().toUpperCase() === "N/A";
148
+ }
149
+
150
+ /** Bit 0 (information unreliable) is accepted only from a nozzle without an identity chip. */
151
+ function unreliableWithoutChip(entry: any, stat: number): boolean {
152
+ return (stat & 1) !== 0 && hasNoIdentityChip(entry);
153
+ }
154
+
155
+ /** Nozzles whose reported diameter and type are configured settings rather than chip readings. */
156
+ export function unchippedNozzles(raw: unknown): number[] {
157
+ const info = (raw as any)?.device?.nozzle?.info;
158
+ if (!Array.isArray(info)) return [];
159
+ return info.flatMap(entry => {
160
+ const index = finiteNumber(entry?.id);
161
+ const stat = finiteNumber(entry?.stat);
162
+ return index !== undefined && Number.isInteger(index) && index >= 0 && index < 16 &&
163
+ stat !== undefined && Number.isInteger(stat) && unreliableWithoutChip(entry, stat) ? [index] : [];
164
+ });
165
+ }
166
+
167
+ const loggedUnchippedNozzles = new Set<string>();
168
+ /** Name each unchipped nozzle a check accepted once per printer on stderr, so headless runs still record it. */
169
+ function logUnchippedNozzles(raw: Record<string, any>, serial: string): void {
170
+ for (const index of unchippedNozzles(raw)) {
171
+ const key = `${serial}:${index}`;
172
+ if (loggedUnchippedNozzles.has(key)) continue;
173
+ loggedUnchippedNozzles.add(key);
174
+ console.error(`Printer ${serial || "(unknown serial)"} nozzle ${index} reports no identity chip; its diameter and type are the printer's configured settings.`);
175
+ }
176
+ }
177
+
141
178
  function reportedNozzles(raw: Record<string,any>): ReportedNozzle[] {
142
179
  const info = raw.device?.nozzle?.info;
143
180
  const output: ReportedNozzle[] = [];
@@ -149,8 +186,11 @@ function reportedNozzles(raw: Record<string,any>): ReportedNozzle[] {
149
186
  const diameter = finiteNumber(entry?.diameter);
150
187
  if (index === undefined || !Number.isInteger(index) || index < 0 || diameter === undefined || diameter <= 0) throw new Error("Printer reported malformed nozzle identity or diameter.");
151
188
  if (entry.stat !== undefined) {
189
+ // BambuStudio DevNozzle: bit 0 set means the information is unreliable;
190
+ // bits 1-2 are 0 normal, 1 abnormal, 2 unknown.
152
191
  const stat = finiteNumber(entry.stat);
153
- if (stat === undefined || !Number.isInteger(stat) || stat < 0 || (stat & 7) !== 0) throw new Error(`Printer nozzle ${index} information is unreliable, abnormal or unknown.`);
192
+ if (stat === undefined || !Number.isInteger(stat) || stat < 0 || (stat & 6) !== 0) throw new Error(`Printer nozzle ${index} information is unreliable, abnormal or unknown.`);
193
+ if ((stat & 1) !== 0 && !unreliableWithoutChip(entry, stat)) throw new Error(`Printer nozzle ${index} information is unreliable; refresh the nozzle information on the printer.`);
154
194
  }
155
195
  if (output.some(nozzle => nozzle.index === index)) throw new Error("Printer reported ambiguous duplicate nozzle indices.");
156
196
  output.push({index,diameter,...nozzleTypeAndFlow(entry.type)});
@@ -398,6 +438,7 @@ export function validatePrinterState(status: any, requirements: PrinterStateRequ
398
438
  filaments.push({position,material,trayIndex,nozzleMin,nozzleMax});
399
439
  }
400
440
  }
441
+ if (nozzles.length) logUnchippedNozzles(raw, identity.observedSerial ?? String(status.serial ?? ""));
401
442
  return {model,reportedNozzles:nozzles,filaments};
402
443
  }
403
444