bambu-printer-mcp 1.1.6 → 1.1.8

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
@@ -13,6 +13,7 @@ import {
13
13
  } from "@modelcontextprotocol/sdk/types.js";
14
14
  import dotenv from "dotenv";
15
15
  import fs from "fs";
16
+ import os from "node:os";
16
17
  import path from "path";
17
18
  import { createServer as createHttpServer, type IncomingMessage, type Server as HttpServer, type ServerResponse } from "node:http";
18
19
  import { randomUUID } from "node:crypto";
@@ -24,6 +25,7 @@ import {
24
25
  type SlicerType,
25
26
  } from "./stl/stl-manipulator.js";
26
27
  import { BambuNetworkBridge, type BambuNetworkBridgeOptions } from "./bambu-network-bridge.js";
28
+ import { BlenderMcpBridge } from "./blender-mcp-bridge.js";
27
29
  import { hasAmsMappingInput, normalizeAmsMappingObject, normalizeBridgeAmsTrayValue } from "./ams-mapping.js";
28
30
  import { analyze3MFAmsRequirements, analyze3MFPlateObjects, analyzeCollarCharm3MF, extractBambuTemplateSettings, getCollarCharmRolePolicy, parse3MF } from './3mf_parser.js';
29
31
  import type { ThreeMFAmsRequirements } from "./types.js";
@@ -36,7 +38,23 @@ const DEFAULT_BAMBU_SERIAL = process.env.BAMBU_PRINTER_SERIAL || process.env.BAM
36
38
  const DEFAULT_BAMBU_TOKEN =
37
39
  process.env.BAMBU_PRINTER_ACCESS_TOKEN || process.env.BAMBU_TOKEN || "";
38
40
  const DEFAULT_BAMBU_DEV_ID = process.env.BAMBU_DEV_ID || DEFAULT_BAMBU_SERIAL;
39
- const TEMP_DIR = process.env.TEMP_DIR || path.join(process.cwd(), "temp");
41
+ // os.tmpdir(), not process.cwd() — a packaged Claude Desktop extension is
42
+ // spawned with a cwd we don't control (and may not be writable), so a
43
+ // cwd-relative default here can throw at module load via the mkdirSync
44
+ // below and kill the server before it ever opens the stdio transport.
45
+ const AUTOMATIC_TEMP_DIR = process.env.TEMP_DIR ? undefined : fs.mkdtempSync(path.join(os.tmpdir(), "bambu-printer-mcp-"));
46
+ const TEMP_DIR = process.env.TEMP_DIR || AUTOMATIC_TEMP_DIR!;
47
+ if (AUTOMATIC_TEMP_DIR) {
48
+ // The exit event also covers startup failures and normal event-loop exit.
49
+ // Only the directory created above belongs to us; explicit TEMP_DIR is retained.
50
+ process.once("exit", () => {
51
+ try {
52
+ fs.rmSync(AUTOMATIC_TEMP_DIR, { recursive: true, force: true });
53
+ } catch (error) {
54
+ console.error("Unable to remove the server's temporary directory:", error);
55
+ }
56
+ });
57
+ }
40
58
 
41
59
  // Printer model and bed type
42
60
  const DEFAULT_BAMBU_MODEL =
@@ -1015,16 +1033,18 @@ class BambuPrinterMCPServer {
1015
1033
  private server: Server;
1016
1034
  private bambu: BambuImplementation;
1017
1035
  private bambuNetwork: BambuNetworkBridge;
1036
+ private readonly blender = new BlenderMcpBridge();
1018
1037
  private stlManipulator: STLManipulator;
1019
1038
  private readonly runtimeConfig: RuntimeConfig;
1020
1039
  private httpRuntime?: { transport: StreamableHTTPServerTransport; httpServer: HttpServer };
1040
+ private shuttingDown = false;
1021
1041
 
1022
1042
  constructor() {
1023
1043
  this.runtimeConfig = readRuntimeConfig();
1024
1044
  this.server = new Server(
1025
1045
  {
1026
1046
  name: "bambu-printer-mcp",
1027
- version: "1.0.0"
1047
+ version: JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version
1028
1048
  },
1029
1049
  {
1030
1050
  capabilities: {
@@ -2637,29 +2657,63 @@ class BambuPrinterMCPServer {
2637
2657
  required: ["stl_path"]
2638
2658
  }
2639
2659
  },
2660
+ {
2661
+ name: "blender_mcp_status",
2662
+ description: "Inspect Blender MCP configuration or connect and discover the remote server's tools and schemas. Connecting does not edit the scene; use get_scene_info through blender_mcp_call to check the Blender addon.",
2663
+ inputSchema: {
2664
+ type: "object",
2665
+ properties: {
2666
+ connect: { type: "boolean", description: "Initialize the configured stdio MCP server and discover its tools (default false)." },
2667
+ timeout_ms: { type: "integer", minimum: 100, maximum: 300000, description: "Total connection and discovery deadline in milliseconds; defaults to BLENDER_MCP_TIMEOUT_MS or 120000." }
2668
+ },
2669
+ additionalProperties: false
2670
+ }
2671
+ },
2672
+ {
2673
+ name: "blender_mcp_call",
2674
+ description: "Call a discovered tool on the configured Blender MCP server, preserving its full MCP content and errors. Discover tool schemas with blender_mcp_status first; execute_blender_code accepts Python code and user_prompt. Calls can modify the active Blender scene and are never automatically retried.",
2675
+ inputSchema: {
2676
+ type: "object",
2677
+ properties: {
2678
+ tool_name: { type: "string", description: "Exact name advertised by Blender MCP, such as get_scene_info or execute_blender_code." },
2679
+ arguments: { type: "object", description: "Arguments matching the remote tool's discovered input schema. Preserve the user's own words in user_prompt when the remote tool requests it." },
2680
+ timeout_ms: { type: "integer", minimum: 100, maximum: 300000, description: "Total connection, discovery, and tool deadline in milliseconds; defaults to BLENDER_MCP_TIMEOUT_MS or 120000." }
2681
+ },
2682
+ required: ["tool_name"],
2683
+ additionalProperties: false
2684
+ }
2685
+ },
2640
2686
  {
2641
2687
  name: "blender_mcp_edit_model",
2642
- description: "Send STL-edit instructions to a Blender MCP bridge command for advanced model edits",
2688
+ description: "Import, edit, and export a local STL through standard Blender MCP with verified output and existing scene objects preserved. Requires a shared local filesystem and Blender Object Mode. Also supports a separately configured legacy executable bridge.",
2643
2689
  inputSchema: {
2644
2690
  type: "object",
2645
2691
  properties: {
2646
2692
  stl_path: { type: "string", description: "Path to the local STL file" },
2647
2693
  operations: {
2648
2694
  type: "array",
2649
- description: "Ordered edit operations for Blender (e.g. remesh, boolean, decimate)",
2695
+ description: "Ordered operations: decimate:<ratio greater than 0 and at most 1>, remesh:<positive voxel size in STL units>, boolean_union:<STL path>. Legacy custom bridges define their own operations.",
2696
+ minItems: 1,
2697
+ maxItems: 64,
2650
2698
  items: { type: "string" }
2651
2699
  },
2652
- bridge_command: { type: "string", description: "Override command for invoking Blender MCP bridge. Per-call overrides require MCP_ALLOW_EXECUTABLE_ARG=1." },
2653
- execute: { type: "boolean", description: "Execute bridge command (true) or return payload only (false)" }
2700
+ output_path: { type: "string", description: "Required for standard MCP previews and execution: new local STL output path whose parent exists. Existing files are never overwritten. Optional for legacy-only bridge configuration." },
2701
+ user_prompt: { type: "string", description: "The user's own words describing the edit, passed unchanged to Blender MCP." },
2702
+ timeout_ms: { type: "integer", minimum: 100, maximum: 300000, description: "Total Blender request deadline in milliseconds; defaults to BLENDER_MCP_TIMEOUT_MS or 120000." },
2703
+ bridge_command: { type: "string", description: "Legacy custom bridge executable override, not a standard MCP command. Per-call overrides require MCP_ALLOW_EXECUTABLE_ARG=1." },
2704
+ execute: { type: "boolean", description: "Apply edits and export (true) or validate and return the prepared request without connecting (false, default)." }
2654
2705
  },
2655
- required: ["stl_path", "operations"]
2706
+ required: process.env.BLENDER_MCP_COMMAND?.trim()
2707
+ ? ["stl_path", "operations", "output_path"]
2708
+ : ["stl_path", "operations"],
2709
+ additionalProperties: false
2656
2710
  }
2657
2711
  }
2658
2712
  ]
2659
2713
  };
2660
2714
  });
2661
2715
 
2662
- this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
2716
+ this.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
2663
2717
  const { name, arguments: args } = request.params;
2664
2718
 
2665
2719
  const host = String(args?.host || DEFAULT_HOST);
@@ -3229,57 +3283,50 @@ class BambuPrinterMCPServer {
3229
3283
 
3230
3284
  let threeMFPath = String(args.three_mf_path);
3231
3285
 
3232
- // Auto-slice if 3MF has no gcode
3233
- try {
3234
- const JSZip = (await import('jszip')).default;
3235
- const zipData = fs.readFileSync(threeMFPath);
3236
- const zip = await JSZip.loadAsync(zipData);
3237
- const hasGcode = Object.keys(zip.files).some(
3238
- f => f.match(/Metadata\/plate_\d+\.gcode/i) || f.endsWith('.gcode')
3239
- );
3240
- if (!hasGcode) {
3241
- if (printBedType === "supertack_plate") {
3242
- throw new Error(
3243
- 'BambuStudio CLI SuperTack bed type is not verified; use a pre-sliced 3MF for SuperTack or choose textured_plate, cool_plate, engineering_plate, or hot_plate.'
3286
+ // Inspect and auto-slice before upload. Preserve any failure instead of sending an unsliced project.
3287
+ const JSZip = (await import('jszip')).default;
3288
+ const zipData = fs.readFileSync(threeMFPath);
3289
+ const zip = await JSZip.loadAsync(zipData);
3290
+ const hasGcode = Object.values(zip.files).some(
3291
+ entry => !entry.dir && /\.gcode$/i.test(entry.name)
3292
+ );
3293
+ if (!hasGcode) {
3294
+ if (printBedType === "supertack_plate") {
3295
+ throw new Error(
3296
+ 'BambuStudio CLI SuperTack bed type is not verified; use a pre-sliced 3MF for SuperTack or choose textured_plate, cool_plate, engineering_plate, or hot_plate.'
3297
+ );
3298
+ }
3299
+ console.log(`3MF has no gcode — auto-slicing with ${slicerType} for ${printModel}`);
3300
+ const autoSliceOptions: BambuSliceOptions = {
3301
+ uptodate: true,
3302
+ ensureOnBed: true,
3303
+ minSave: true,
3304
+ skipModifiedGcodes: true,
3305
+ bedType: printBedType,
3306
+ };
3307
+ if (!explicitSlicerProfile) {
3308
+ try {
3309
+ const liveFilaments = await this.getResolvedPrinterFilamentInventory(
3310
+ host,
3311
+ bambuSerial,
3312
+ bambuToken,
3313
+ printModel,
3314
+ printNozzle
3244
3315
  );
3245
- }
3246
- console.log(`3MF has no gcode — auto-slicing with ${slicerType} for ${printModel}`);
3247
- const autoSliceOptions: BambuSliceOptions = {
3248
- uptodate: true,
3249
- ensureOnBed: true,
3250
- minSave: true,
3251
- skipModifiedGcodes: true,
3252
- bedType: printBedType,
3253
- };
3254
- if (!explicitSlicerProfile) {
3255
- try {
3256
- const liveFilaments = await this.getResolvedPrinterFilamentInventory(
3257
- host,
3258
- bambuSerial,
3259
- bambuToken,
3260
- printModel,
3261
- printNozzle
3262
- );
3263
- if (liveFilaments.recommended?.load_filaments) {
3264
- autoSliceOptions.loadFilaments = liveFilaments.recommended.load_filaments;
3265
- }
3266
- } catch (filamentError) {
3267
- console.warn("Could not resolve live printer filaments for auto-slicing:", filamentError);
3316
+ if (liveFilaments.recommended?.load_filaments) {
3317
+ autoSliceOptions.loadFilaments = liveFilaments.recommended.load_filaments;
3268
3318
  }
3319
+ } catch (filamentError) {
3320
+ console.warn("Could not resolve live printer filaments for auto-slicing:", filamentError);
3269
3321
  }
3270
- threeMFPath = await this.stlManipulator.sliceSTL(
3271
- threeMFPath, slicerType, slicerPath, activeSlicerProfile,
3272
- undefined, // progressCallback
3273
- printPreset,
3274
- autoSliceOptions
3275
- );
3276
- console.log("Auto-sliced to: " + threeMFPath);
3277
- }
3278
- } catch (sliceCheckErr: any) {
3279
- if (String(sliceCheckErr?.message || "").includes("SuperTack")) {
3280
- throw sliceCheckErr;
3281
3322
  }
3282
- console.warn("Could not check/slice 3MF, proceeding with original:", sliceCheckErr.message);
3323
+ threeMFPath = await this.stlManipulator.sliceSTL(
3324
+ threeMFPath, slicerType, slicerPath, activeSlicerProfile,
3325
+ undefined, // progressCallback
3326
+ printPreset,
3327
+ autoSliceOptions
3328
+ );
3329
+ console.log("Auto-sliced to: " + threeMFPath);
3283
3330
  }
3284
3331
 
3285
3332
  const parsed3MFData = await parse3MF(threeMFPath);
@@ -3496,21 +3543,24 @@ class BambuPrinterMCPServer {
3496
3543
  result = await this.stlManipulator.layFlat(String(args.stl_path));
3497
3544
  break;
3498
3545
 
3546
+ case "blender_mcp_status":
3547
+ result = await this.blender.status(args ?? {}, extra.signal);
3548
+ break;
3549
+
3550
+ case "blender_mcp_call":
3551
+ return await this.blender.call(args ?? {}, extra.signal);
3552
+
3499
3553
  case "blender_mcp_edit_model":
3500
- if (!args?.stl_path || !Array.isArray(args.operations)) {
3501
- throw new Error("Missing required parameters: stl_path and operations");
3502
- }
3503
- result = await this.invokeBlenderBridge({
3504
- stlPath: String(args.stl_path),
3505
- operations: args.operations.map((entry: any) => String(entry)),
3506
- execute: Boolean(args.execute ?? false),
3507
- bridgeCommand: this.resolveExecutableSelectorArg(
3508
- args.bridge_command,
3554
+ result = await this.blender.edit(
3555
+ args ?? {},
3556
+ this.resolveExecutableSelectorArg(
3557
+ args?.bridge_command,
3509
3558
  "blender_mcp_edit_model",
3510
3559
  "bridge_command",
3511
3560
  true
3512
- ) ?? this.runtimeConfig.blenderBridgeCommand,
3513
- });
3561
+ ) ?? (!process.env.BLENDER_MCP_COMMAND?.trim() ? this.runtimeConfig.blenderBridgeCommand : undefined),
3562
+ extra.signal
3563
+ );
3514
3564
  break;
3515
3565
 
3516
3566
  default:
@@ -3547,49 +3597,27 @@ class BambuPrinterMCPServer {
3547
3597
  });
3548
3598
  }
3549
3599
 
3550
- private async invokeBlenderBridge(params: {
3551
- stlPath: string;
3552
- operations: string[];
3553
- execute: boolean;
3554
- bridgeCommand?: string;
3555
- }): Promise<any> {
3556
- const payload = {
3557
- stlPath: params.stlPath,
3558
- operations: params.operations,
3559
- };
3560
-
3561
- if (!params.execute || !params.bridgeCommand) {
3562
- return {
3563
- status: "prepared",
3564
- payload,
3565
- note: params.bridgeCommand
3566
- ? "Set execute=true to run the Blender bridge command."
3567
- : "No BLENDER_MCP_BRIDGE_COMMAND configured. Set the env var or pass bridge_command.",
3568
- };
3569
- }
3570
-
3571
- const { execFile } = await import("node:child_process");
3572
- const { promisify } = await import("node:util");
3573
- const execFileAsync = promisify(execFile);
3574
-
3575
- const { stdout, stderr } = await execFileAsync(params.bridgeCommand, [], {
3576
- env: { ...process.env, MCP_BLENDER_PAYLOAD: JSON.stringify(payload) },
3577
- timeout: 120_000,
3578
- });
3579
-
3580
- return {
3581
- status: "executed",
3582
- stdout: stdout.trim(),
3583
- stderr: stderr.trim(),
3584
- };
3585
- }
3586
-
3587
3600
  async startStdio() {
3588
3601
  const transport = new StdioServerTransport();
3589
3602
  await this.server.connect(transport);
3603
+ process.stdin.once("end", () => { void this.shutdown(); });
3590
3604
  console.error("Bambu Printer MCP server running on stdio");
3591
3605
  }
3592
3606
 
3607
+ async shutdown(exitCode = 0) {
3608
+ if (this.shuttingDown) return;
3609
+ this.shuttingDown = true;
3610
+ try {
3611
+ // Closing the MCP transport aborts its active requests before exit cleanup.
3612
+ await this.server.close();
3613
+ this.httpRuntime?.httpServer.close();
3614
+ } catch (error) {
3615
+ console.error("[MCP Shutdown]", error);
3616
+ } finally {
3617
+ process.exit(exitCode);
3618
+ }
3619
+ }
3620
+
3593
3621
  async startHttp() {
3594
3622
  const { httpHost, httpPort, httpPath, statefulSession, enableJsonResponse, allowedOrigins } =
3595
3623
  this.runtimeConfig;
@@ -3640,4 +3668,6 @@ class BambuPrinterMCPServer {
3640
3668
  }
3641
3669
 
3642
3670
  const server = new BambuPrinterMCPServer();
3671
+ process.once("SIGINT", () => { void server.shutdown(130); });
3672
+ process.once("SIGTERM", () => { void server.shutdown(143); });
3643
3673
  server.run().catch(console.error);
@@ -62,6 +62,10 @@ function isH2ModelName(model: unknown): boolean {
62
62
  return H2_MODEL_NAMES.has(String(model ?? "").trim().toLowerCase().replace(/\s+/g, " "));
63
63
  }
64
64
 
65
+ function isP2SModelName(model: unknown): boolean {
66
+ return String(model ?? "").trim().toLowerCase() === "p2s";
67
+ }
68
+
65
69
  interface BambuPrintOptionsInternal {
66
70
  projectName: string;
67
71
  filePath: string;
@@ -145,11 +149,14 @@ class TolerantBambuClient extends BambuClient {
145
149
  const sn = this.config.serialNumber;
146
150
  if (sn.startsWith("093")) return "H2S";
147
151
  if (sn.startsWith("094")) return "H2D";
152
+ if (sn.startsWith("239")) return "H2C";
153
+ if (sn.startsWith("31B")) return "H2DPRO";
148
154
  if (sn.startsWith("00M")) return "X1C";
149
155
  if (sn.startsWith("00W")) return "X1";
150
156
  if (sn.startsWith("03W")) return "X1E";
151
157
  if (sn.startsWith("01S")) return "P1P";
152
158
  if (sn.startsWith("01P")) return "P1S";
159
+ if (sn.startsWith("22E")) return "P2S";
153
160
  if (sn.startsWith("030")) return "A1";
154
161
  if (sn.startsWith("039")) return "A1M";
155
162
  return undefined;
@@ -572,27 +579,36 @@ export class BambuImplementation {
572
579
  remoteFileName = remoteFileName.replace(/\.gcode\.3mf\.gcode\.3mf$/i, ".gcode.3mf");
573
580
 
574
581
  // H2-series printers land files at the FTP root and reference them via ftp:///<name>.
575
- // P1/A1/X1 use /cache/<name> and file:///sdcard/cache/<name>.
582
+ // Full-size A1 uses SD root/project_file (reported on firmware 01.08.01.00).
583
+ // P1/X1/A1 mini retain their legacy cache/gcode_file route.
584
+ // P2S keeps the /cache/<name> upload but only accepts the H2-style
585
+ // project_file command, referenced via ftp:///cache/<name> (verified on
586
+ // P2S firmware 01.02.00.00; file:///sdcard/... fails with ERROR STATE).
576
587
  const isH2 =
577
588
  serial.startsWith("093") ||
578
589
  serial.startsWith("094") ||
579
590
  isH2ModelName(options.bambuModel);
580
- const remoteProjectPath = isH2 ? remoteFileName : `cache/${remoteFileName}`;
581
- const remoteUploadPath = isH2 ? `/${remoteFileName}` : `/cache/${remoteFileName}`;
582
- const projectUrl = isH2
583
- ? `ftp:///${remoteFileName}`
591
+ const isP2S = serial.startsWith("22E") || isP2SModelName(options.bambuModel);
592
+ const isA1 = String(options.bambuModel ?? "").trim().toLowerCase() === "a1" ||
593
+ (!options.bambuModel && serial.startsWith("030"));
594
+ const usesH2ProjectFile = isH2 || isP2S;
595
+ const remoteProjectPath = isH2 || isA1 ? remoteFileName : `cache/${remoteFileName}`;
596
+ const remoteUploadPath = `/${remoteProjectPath}`;
597
+ const projectUrl = usesH2ProjectFile
598
+ ? `ftp:///${remoteProjectPath}`
584
599
  : `file:///sdcard/${remoteProjectPath}`;
585
600
 
586
601
  // Upload via basic-ftp directly (bypasses bambu-js double-path bug)
587
602
  await this.ftpUpload(host, token, options.filePath, remoteUploadPath);
588
603
 
589
604
  // Pre-sliced .gcode.3mf files: routing depends on firmware generation.
590
- // P1/A1/X1 series: project_file returns 405004002 for .gcode.3mf (firmware
605
+ // Legacy P1/X1/A1 mini: project_file returns 405004002 for .gcode.3mf (firmware
591
606
  // doesn't recognise the container), so use gcode_file instead.
592
- // H2-series: gcode_file is not supported; project_file works because the
607
+ // H2-series and P2S: gcode_file is not supported (P2S answers 0500-4002
608
+ // "Unsupported file path or name"); project_file works because the
593
609
  // firmware can open the zip and find Metadata/plate_<n>.gcode directly.
594
610
  if (options.filePath.toLowerCase().endsWith(".gcode.3mf")) {
595
- if (!isH2) {
611
+ if (!usesH2ProjectFile && !isA1) {
596
612
  const printer = await this.getPrinter(host, serial, token);
597
613
  await invokeWithoutAck(printer, new GCodeFileCommand({ fileName: remoteProjectPath }));
598
614
  return {
@@ -601,7 +617,7 @@ export class BambuImplementation {
601
617
  remoteProjectPath,
602
618
  };
603
619
  }
604
- // H2-series: fall through to project_file path below
620
+ // H2-series and P2S: fall through to project_file path below
605
621
  }
606
622
 
607
623
  const projectMetadata = await this.resolveProjectFileMetadata(
@@ -687,7 +703,7 @@ export class BambuImplementation {
687
703
 
688
704
  let amsMapping: number[];
689
705
  let amsMapping2: Array<{ ams_id: number; slot_id: number }>;
690
- if (isH2) {
706
+ if (usesH2ProjectFile) {
691
707
  const projLen = Math.max(projectMetadata.projectFilamentCount, baseMapping.length, 1);
692
708
  amsMapping = Array.from({ length: projLen }, (_, i) =>
693
709
  i < baseMapping.length ? baseMapping[i] : -1
@@ -699,7 +715,7 @@ export class BambuImplementation {
699
715
  return { ams_id: Math.floor(v / 4), slot_id: v % 4 };
700
716
  });
701
717
  } else {
702
- amsMapping = Array.from({ length: 5 }, (_, i) =>
718
+ amsMapping = Array.from({ length: Math.max(5, baseMapping.length, projectMetadata.projectFilamentCount) }, (_, i) =>
703
719
  i < baseMapping.length ? baseMapping[i] : -1
704
720
  );
705
721
  amsMapping2 = [];
@@ -707,7 +723,7 @@ export class BambuImplementation {
707
723
 
708
724
  const b = (v: any) => (v ? 1 : 0);
709
725
  let projectFileCmd: Record<string, any>;
710
- if (isH2) {
726
+ if (usesH2ProjectFile) {
711
727
  const submissionId = String(Date.now() & 0x7fffffff);
712
728
  projectFileCmd = {
713
729
  print: {
@@ -1270,7 +1286,7 @@ export class BambuImplementation {
1270
1286
  // integration's models.py shows the printer reports its own
1271
1287
  // `ipcam.rtsp_url` for these models, and Parker (H2S) rejects the
1272
1288
  // A1/P1 80-byte auth packet on port 6000 (verified 2026-04-27 --
1273
- // see PROGRESS.md "H2 probe results").
1289
+ // confirmed by local H2 camera transport probes).
1274
1290
  const RTSP_MODELS = new Set([
1275
1291
  "x1", "x1c", "x1carbon", "x1e", "p2s",
1276
1292
  "h2", "h2s", "h2d", "h2c", "h2dpro",