bambu-js
Version:
Tools to interact with Bambu Lab printers
444 lines • 19.7 kB
JavaScript
import * as tls from "node:tls";
import { isSupportedModel, ModelCameraRegistry, } from "./models/index.js";
import { CameraProtocol, } from "./types/camera-schema.js";
import { CameraError, CameraConnectionError, CameraAuthenticationError, CameraTimeoutError, CameraFrameError, CameraValidationError, } from "./utilities/camera-errors.js";
import { JPEGValidator, ResourceManager } from "./utilities/camera-utils.js";
/**
* Abstract base class for camera frame capture implementations.
*/
class CameraFrameCaptureBase {
host;
accessCode;
config;
options;
frameNumber = 0;
constructor(host, accessCode, config, options) {
this.host = host;
this.accessCode = accessCode;
this.config = config;
this.options = options;
}
}
/**
* TCP-based camera frame capture implementation (for P1S and similar models).
*/
class TCPFrameCapture extends CameraFrameCaptureBase {
async captureFrame() {
return new Promise((resolve, reject) => {
let socket = null;
let frameBuffer = Buffer.alloc(0);
let isAuthenticated = false;
const resourceManager = new ResourceManager();
// Set connection timeout
const connectionTimeout = setTimeout(() => {
resourceManager.cleanup();
reject(new CameraTimeoutError("Frame capture timeout"));
}, this.options.connectionTimeout);
resourceManager.addCleanup(() => {
clearTimeout(connectionTimeout);
});
resourceManager.addCleanup(() => {
if (socket) {
socket.removeAllListeners();
if (!socket.destroyed) {
socket.destroy();
}
socket = null;
}
});
try {
// Create TLS socket
socket = tls.connect({
host: this.host,
port: this.config.port,
rejectUnauthorized: false,
});
// Handle connection
const onConnect = () => {
if (!socket)
return;
this.sendAuthenticationPacket(socket);
};
socket.once("connect", onConnect);
socket.once("secureConnect", onConnect);
// Handle incoming data
socket.on("data", (data) => {
frameBuffer = Buffer.concat([frameBuffer, data]);
if (!isAuthenticated) {
// Check if authentication was successful
if (frameBuffer.length >= 16) {
const payloadSize = frameBuffer.readUInt32LE(0);
const frameType = frameBuffer.readUInt32LE(4);
// Check if this looks like a valid frame header
if (payloadSize > 0 &&
payloadSize <= this.options.maxFrameSize &&
frameType !== 0xffffffff) {
// 0xFFFFFFFF might indicate an error response
isAuthenticated = true;
}
else if (frameBuffer.length > 100) {
// If we have a lot of data but no valid frame header,
// assume authentication failed and reject
resourceManager.cleanup();
reject(new CameraAuthenticationError("Authentication failed - invalid response from server"));
return;
}
}
// Add a reasonable timeout for authentication
if (frameBuffer.length === 0) {
setTimeout(() => {
if (!isAuthenticated && frameBuffer.length === 0) {
resourceManager.cleanup();
reject(new CameraAuthenticationError("Authentication timeout - no response from server"));
}
}, Math.min(this.options.connectionTimeout / 2, 3000));
}
if (!isAuthenticated)
return;
}
// Process frame data to capture a single frame
try {
const frame = this.extractSingleFrame(frameBuffer);
if (frame) {
resourceManager.cleanup();
resolve(frame);
}
}
catch (error) {
resourceManager.cleanup();
reject(error instanceof CameraError
? error
: new CameraFrameError(`Frame extraction failed: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined));
}
});
socket.on("error", (error) => {
resourceManager.cleanup();
// Check for specific error types and categorize appropriately
const errorMessage = error.message.toLowerCase();
if (errorMessage.includes("enotfound") ||
errorMessage.includes("host not found") ||
errorMessage.includes("getaddrinfo") ||
errorMessage.includes("dns")) {
reject(new CameraConnectionError(`Failed to resolve host: ${error.message}`, error));
}
else if (errorMessage.includes("econnrefused") ||
errorMessage.includes("connection refused")) {
reject(new CameraConnectionError(`Connection refused: ${error.message}`, error));
}
else if (errorMessage.includes("timeout") ||
errorMessage.includes("etimedout")) {
reject(new CameraTimeoutError(`Connection timeout: ${error.message}`, error));
}
else if (errorMessage.includes("econnreset") ||
errorMessage.includes("connection reset")) {
reject(new CameraConnectionError(`Connection reset: ${error.message}`, error));
}
else {
reject(new CameraConnectionError(`TCP connection error: ${error.message}`, error));
}
});
socket.on("close", () => {
resourceManager.cleanup();
if (!isAuthenticated) {
reject(new CameraAuthenticationError("Connection closed before authentication"));
}
else {
reject(new CameraConnectionError("Connection closed before frame capture"));
}
});
}
catch (error) {
resourceManager.cleanup();
reject(new CameraConnectionError(`Failed to create socket: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined));
}
});
}
sendAuthenticationPacket(socket) {
const authPacket = Buffer.alloc(80);
// Header (16 bytes)
authPacket.writeUInt32LE(0x40, 0); // Payload size (64 bytes)
authPacket.writeUInt32LE(0x3000, 4); // Type
authPacket.writeUInt32LE(0, 8); // Flags
authPacket.writeUInt32LE(0, 12); // Reserved
// Username (32 bytes, ASCII, null-padded)
const username = Buffer.from(this.config.username, "ascii");
username.copy(authPacket, 16);
// Password (32 bytes, ASCII, null-padded)
const password = Buffer.from(this.accessCode, "ascii");
password.copy(authPacket, 48);
socket.write(authPacket);
}
extractSingleFrame(buffer) {
if (buffer.length < 16)
return null;
// Read frame header (16 bytes)
const payloadSize = buffer.readUInt32LE(0);
// Validate payload size
if (payloadSize <= 0 || payloadSize > this.options.maxFrameSize) {
throw new CameraValidationError(`Invalid payload size: ${payloadSize}`);
}
// Check if we have the complete frame
const totalFrameSize = 16 + payloadSize;
if (buffer.length < totalFrameSize) {
return null; // Wait for more data
}
// Extract image data
const imageData = buffer.subarray(16, totalFrameSize);
// Validate JPEG format
if (!JPEGValidator.isValidJPEG(imageData)) {
throw new CameraValidationError("Received invalid JPEG data");
}
return {
imageData: Buffer.from(imageData),
frameNumber: ++this.frameNumber,
timestamp: new Date(),
size: payloadSize,
};
}
}
/**
* RTSP-based camera frame capture implementation (for H2D and similar models).
*/
class RTSPFrameCapture extends CameraFrameCaptureBase {
path;
port;
constructor(host, accessCode, config, options) {
super(host, accessCode, config, options);
this.port = config.port;
this.path = config.rtsp?.path || "/streaming/live/1";
}
async captureFrame() {
const { spawn } = await import("node:child_process");
return new Promise((resolve, reject) => {
let frameBuffer = Buffer.alloc(0);
const resourceManager = new ResourceManager();
// Set connection timeout
const connectionTimeout = setTimeout(() => {
resourceManager.cleanup();
reject(new CameraTimeoutError("RTSP frame capture timeout"));
}, this.options.connectionTimeout);
resourceManager.addCleanup(() => {
clearTimeout(connectionTimeout);
});
try {
// Build RTSP URL: rtsps://username:accesscode@host:port/path
const rtspUrl = `rtsps://${this.config.username}:${this.accessCode}@${this.host}:${this.port}${this.path}`;
// Spawn ffmpeg process
const ffmpeg = spawn("ffmpeg", [
"-rtsp_transport",
"tcp",
"-i",
rtspUrl,
"-vframes",
"1",
"-f",
"image2",
"-c:v",
"mjpeg",
"-q:v",
"2",
"-",
], {
stdio: ["ignore", "pipe", "pipe"],
});
resourceManager.addCleanup(() => {
if (!ffmpeg.killed) {
ffmpeg.kill();
}
});
// Collect stdout data (image bytes)
ffmpeg.stdout.on("data", (chunk) => {
frameBuffer = Buffer.concat([frameBuffer, chunk]);
});
// Handle stderr for error reporting
let stderrOutput = "";
ffmpeg.stderr.on("data", (chunk) => {
stderrOutput += chunk.toString();
});
// Handle process completion
ffmpeg.on("close", (code) => {
resourceManager.cleanup();
if (code === 0 && frameBuffer.length > 0) {
// Validate JPEG format
if (JPEGValidator.isValidJPEG(frameBuffer)) {
resolve({
imageData: Buffer.from(frameBuffer),
frameNumber: ++this.frameNumber,
timestamp: new Date(),
size: frameBuffer.length,
});
}
else {
reject(new CameraValidationError("Received invalid JPEG data from ffmpeg"));
}
}
else {
// Analyze stderr to determine appropriate error type
const stderr = stderrOutput.toLowerCase();
// Check for authentication errors
if (stderr.includes("401 unauthorized") ||
stderr.includes("authorization failed") ||
stderr.includes("access denied") ||
stderr.includes("authentication")) {
reject(new CameraAuthenticationError(`RTSP authentication failed: ${stderrOutput.slice(-200)}`));
return;
}
// Check for connection errors
if (stderr.includes("failed to resolve hostname") ||
stderr.includes("connection refused") ||
stderr.includes("network unreachable") ||
stderr.includes("host unreachable") ||
stderr.includes("no route to host") ||
stderr.includes("input/output error") ||
stderr.includes("connection timeout") ||
stderr.includes("connection timed out")) {
reject(new CameraConnectionError(`RTSP connection failed: ${stderrOutput.slice(-200)}`));
return;
}
// Check for timeout errors
if (stderr.includes("timeout") || stderr.includes("timed out")) {
reject(new CameraTimeoutError(`RTSP timeout: ${stderrOutput.slice(-200)}`));
return;
}
// Default to frame error for other ffmpeg issues
const errorMessage = code !== 0
? `ffmpeg process exited with code ${code}`
: "No frame data received from ffmpeg";
const fullError = stderrOutput
? `${errorMessage}. stderr: ${stderrOutput.slice(-500)}`
: errorMessage;
reject(new CameraFrameError(fullError));
}
});
// Handle process errors
ffmpeg.on("error", (error) => {
resourceManager.cleanup();
reject(new CameraConnectionError(`Failed to spawn ffmpeg: ${error.message}`, error));
});
}
catch (error) {
resourceManager.cleanup();
reject(new CameraError(`Failed to capture frame: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined));
}
});
}
}
/**
* Controller for capturing frames from Bambu Lab printer cameras.
*/
export class CameraController {
host;
accessCode;
model;
config;
options;
captureImpl;
constructor(host, accessCode, model, options = {}) {
// Validate input parameters
if (!host || typeof host !== "string" || host.trim().length === 0) {
throw new CameraValidationError("Host must be a non-empty string");
}
if (!accessCode ||
typeof accessCode !== "string" ||
accessCode.trim().length === 0) {
throw new CameraValidationError("Access code must be a non-empty string");
}
if (!isSupportedModel(model)) {
throw new CameraValidationError(`Unsupported model: ${model}. Supported models: ${Object.keys(ModelCameraRegistry).join(", ")}`);
}
this.host = host.trim();
this.accessCode = accessCode.trim();
this.model = model;
this.config = ModelCameraRegistry[model];
this.options = {
connectionTimeout: options.connectionTimeout ?? 10000,
maxFrameSize: options.maxFrameSize ?? 2 * 1024 * 1024, // 2MB
};
// Validate options
if (this.options.connectionTimeout <= 0) {
throw new CameraValidationError("Connection timeout must be greater than 0");
}
if (this.options.maxFrameSize <= 0) {
throw new CameraValidationError("Max frame size must be greater than 0");
}
// Create the appropriate capture implementation based on protocol
this.captureImpl = this.createCaptureImplementation();
}
/**
* Creates a new camera controller.
* @param config - Configuration object containing printer information.
* @returns A new CameraController instance
* @throws {CameraValidationError} When configuration is invalid
*/
static create(config) {
if (!config || typeof config !== "object") {
throw new CameraValidationError("Configuration object is required");
}
const { model, host, accessCode, options } = config;
if (!model) {
throw new CameraValidationError("Model is required in configuration");
}
if (!host) {
throw new CameraValidationError("Host is required in configuration");
}
if (!accessCode) {
throw new CameraValidationError("Access code is required in configuration");
}
return new CameraController(host, accessCode, model, options);
}
/**
* Creates the appropriate capture implementation based on the model's protocol.
* @throws {CameraError} When protocol is not supported
*/
createCaptureImplementation() {
switch (this.config.protocol) {
case CameraProtocol.TCP_STREAM:
return new TCPFrameCapture(this.host, this.accessCode, this.config, this.options);
case CameraProtocol.RTSP:
return new RTSPFrameCapture(this.host, this.accessCode, this.config, this.options);
default:
throw new CameraError(`Unsupported protocol: ${this.config.protocol}`);
}
}
/**
* Gets the camera configuration for the current model.
*/
getCameraConfig() {
return { ...this.config };
}
/**
* Gets the protocol being used for this camera.
*/
getProtocol() {
return this.config.protocol;
}
/**
* Gets the printer model.
*/
getModel() {
return this.model;
}
/**
* Gets the printer host.
*/
getHost() {
return this.host;
}
/**
* Gets the printer access code.
*/
getAccessCode() {
return this.accessCode;
}
/**
* Captures a single frame from the camera.
* Creates a new connection, authenticates, captures one frame, and disconnects.
*
* @returns Promise that resolves to a CameraFrame containing the image data
* @throws {CameraError} When frame capture fails
*/
async captureFrame() {
return this.captureImpl.captureFrame();
}
}
//# sourceMappingURL=camera-controller.js.map