import { isAxiosError, type AxiosRequestConfig, type AxiosResponse } from "axios"; import { PrinterImplementation } from "../types.js"; import fs from "fs"; import FormData from "form-data"; type RequestCandidate = { route: string; data?: unknown; }; export class PrusaImplementation extends PrinterImplementation { private buildAuthHeaders(apiKey: string): Record { return { Accept: "application/json", "X-Api-Key": apiKey, Authorization: `Bearer ${apiKey}`, }; } private buildBaseUrl(host: string, port: string): string { const trimmedHost = host.trim(); const trimmedPort = port.trim(); if (!trimmedHost) { throw new Error("Prusa host is required."); } const rawBaseUrl = /^[a-z][a-z\d+.-]*:\/\//i.test(trimmedHost) ? trimmedHost : `${this.defaultProtocolFor(trimmedHost, trimmedPort)}://${trimmedHost}`; const parsed = new URL(rawBaseUrl); if (trimmedPort && !parsed.port && !this.isDefaultPort(parsed.protocol, trimmedPort)) { parsed.port = trimmedPort; } return parsed.toString().replace(/\/$/, ""); } private defaultProtocolFor(host: string, port: string): "http" | "https" { if (port === "443" || this.isPrusaConnectCloudHost(host)) { return "https"; } return "http"; } private isDefaultPort(protocol: string, port: string): boolean { return (protocol === "https:" && port === "443") || (protocol === "http:" && port === "80"); } private isPrusaConnectCloudHost(host: string): boolean { try { const parsed = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(host) ? host : `https://${host}`); return parsed.hostname.toLowerCase() === "connect.prusa3d.com"; } catch { return host.toLowerCase().split(":")[0] === "connect.prusa3d.com"; } } private isFallbackStatus(error: unknown): boolean { if (!isAxiosError(error)) { return false; } const status = error.response?.status; return status === 404 || status === 405 || status === 501; } private async getWithFallback( host: string, port: string, apiKey: string, routes: string[] ): Promise { const baseUrl = this.buildBaseUrl(host, port); let lastError: unknown; for (const route of routes) { try { return await this.apiClient.get(`${baseUrl}${route}`, { headers: this.buildAuthHeaders(apiKey), }); } catch (error) { lastError = error; if (!this.isFallbackStatus(error)) { throw error; } } } throw lastError ?? new Error("No compatible Prusa GET endpoint found."); } private async postWithFallback( host: string, port: string, apiKey: string, candidates: RequestCandidate[], config?: AxiosRequestConfig ): Promise { const baseUrl = this.buildBaseUrl(host, port); let lastError: unknown; for (const candidate of candidates) { try { return await this.apiClient.post(`${baseUrl}${candidate.route}`, candidate.data as any, { ...(config ?? {}), headers: { ...this.buildAuthHeaders(apiKey), ...(config?.headers ?? {}), }, }); } catch (error) { lastError = error; if (!this.isFallbackStatus(error)) { throw error; } } } throw lastError ?? new Error("No compatible Prusa POST endpoint found."); } async getStatus(host: string, port: string, apiKey: string) { const response = await this.getWithFallback(host, port, apiKey, [ "/api/v1/status", "/api/v1/printer", "/api/printer", ]); return response.data; } async getFiles(host: string, port: string, apiKey: string) { const response = await this.getWithFallback(host, port, apiKey, [ "/api/v1/storage", "/api/files", "/api/files/local", ]); return response.data; } async getFile(host: string, port: string, apiKey: string, filename: string) { const encodedFile = encodeURIComponent(filename); const response = await this.getWithFallback(host, port, apiKey, [ `/api/v1/storage/${encodedFile}`, `/api/files/local/${encodedFile}`, ]); return response.data; } async uploadFile( host: string, port: string, apiKey: string, filePath: string, filename: string, print: boolean ) { const formData = new FormData(); formData.append("file", fs.createReadStream(filePath)); formData.append("filename", filename); const response = await this.postWithFallback( host, port, apiKey, [ { route: "/api/v1/storage", data: formData }, { route: "/api/files/local", data: formData }, ], { headers: { ...formData.getHeaders(), }, } ); if (print) { await this.startJob(host, port, apiKey, filename); } return response.data; } async startJob(host: string, port: string, apiKey: string, filename: string) { const response = await this.postWithFallback(host, port, apiKey, [ { route: "/api/v1/job", data: { command: "start", file: filename, }, }, { route: "/api/v1/job", data: { command: "start", path: filename, }, }, { route: "/api/job", data: { command: "start", file: filename, }, }, { route: "/api/job", data: { command: "start", path: filename, }, }, ]); return response.data; } async cancelJob(host: string, port: string, apiKey: string) { const response = await this.postWithFallback(host, port, apiKey, [ { route: "/api/v1/job", data: { command: "cancel", }, }, { route: "/api/job", data: { command: "cancel", }, }, ]); return response.data; } async setTemperature( host: string, port: string, apiKey: string, component: string, temperature: number ) { const normalized = component.toLowerCase(); if (normalized === "bed") { const response = await this.postWithFallback(host, port, apiKey, [ { route: "/api/v1/printer/temperature", data: { command: "set", target: { bed: temperature }, }, }, { route: "/api/printer/bed", data: { command: "target", target: temperature, }, }, ]); return response.data; } if (normalized.startsWith("extruder") || normalized === "nozzle" || normalized === "tool0") { const response = await this.postWithFallback(host, port, apiKey, [ { route: "/api/v1/printer/temperature", data: { command: "set", target: { tool0: temperature }, }, }, { route: "/api/printer/tool", data: { command: "target", targets: { tool0: temperature }, }, }, ]); return response.data; } throw new Error(`Unsupported component: ${component}`); } }